Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8098b2e00 | ||
|
|
100f8a5165 | ||
|
|
76899d2fdb | ||
|
|
41e8e196ec | ||
|
|
ef5977bcfe | ||
|
|
e94d47bf0f | ||
|
|
f8bbb3065c | ||
|
|
bc908b4534 | ||
|
|
a179719c3e | ||
|
|
2ca3a3f7db | ||
|
|
f2a8cf0930 | ||
|
|
966701f94e | ||
|
|
1a183a4825 | ||
|
|
3a7e3b2168 | ||
|
|
eb2664ac79 | ||
|
|
31743cf962 | ||
|
|
2c2ba2e32c | ||
|
|
4ff2153e15 | ||
|
|
3c6b5c6c1e | ||
|
|
6c1d79fe5b | ||
|
|
e5f1df8c70 | ||
|
|
d6b59f6799 | ||
|
|
4b069edd33 | ||
|
|
a8ac6b2eda | ||
|
|
5c925f0ca5 | ||
|
|
30c7782d6e | ||
|
|
f7480902b4 | ||
|
|
00b5a65859 | ||
|
|
317ac59f47 | ||
|
|
b08c156552 | ||
|
|
2ad7629c2a | ||
|
|
42963ead5c | ||
|
|
c0168ecc5b | ||
|
|
34649d809e | ||
|
|
ee0f6b0ccf | ||
|
|
c8b47d1033 | ||
|
|
3d3ae97af4 | ||
|
|
d7066a2bbc | ||
|
|
7f8545f1dc | ||
|
|
85a63c4b78 | ||
|
|
1852d71974 | ||
|
|
7da7468965 | ||
|
|
83a894823f | ||
|
|
4cb0ac37a6 | ||
|
|
7a6fab8624 | ||
|
|
6416b67c63 | ||
|
|
1e269a6f82 | ||
|
|
8694a3bfe4 | ||
|
|
9e320674c1 | ||
|
|
9047e9e785 | ||
|
|
b8d8f0c16d | ||
|
|
8b8e9b001e | ||
|
|
9c40bd98aa | ||
|
|
1a0608f49e | ||
|
|
3543e0a1b5 | ||
|
|
97c23f75dd | ||
|
|
49bb9980b8 | ||
|
|
fd4cbb43c1 | ||
|
|
40242b7dcc | ||
|
|
9731023014 | ||
|
|
0abbba7a56 | ||
|
|
cc809183ae | ||
|
|
f7b3f4ed76 | ||
|
|
04fc954769 | ||
|
|
3621b8c27c | ||
|
|
358959522d | ||
|
|
19cd5962aa | ||
|
|
961336f35c | ||
|
|
573fa9f7f3 | ||
|
|
22ce6b047a | ||
|
|
82525f382d | ||
|
|
ec9dd08970 | ||
|
|
9ec0280334 | ||
|
|
0861dbd75d | ||
|
|
f5949b6b9a | ||
|
|
99905497e1 | ||
|
|
4ae18b73d1 | ||
|
|
1da26e041d | ||
|
|
4e42911f3d | ||
|
|
b0045a910c | ||
|
|
9491f07ade | ||
|
|
d994339de5 | ||
|
|
0464054a2b | ||
|
|
b062164277 | ||
|
|
ff9acab0fc | ||
|
|
76e09447c8 | ||
|
|
9470699798 | ||
|
|
89bfa82c61 | ||
|
|
859131308a | ||
|
|
56d02673ed | ||
|
|
b28e9c120c |
31
.github/workflows/coverage.yaml
vendored
31
.github/workflows/coverage.yaml
vendored
@@ -19,20 +19,40 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4']
|
||||
php: ['8.0']
|
||||
|
||||
name: Coverage (${{ matrix.php }})
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: shivammathur/setup-php@v2
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: pcov
|
||||
extensions: mbstring, xml, ctype, iconv, intl, mysql, zip, gd, ldap
|
||||
- uses: ramsey/composer-install@v1
|
||||
- run: composer require laminas/laminas-ldap
|
||||
|
||||
- name: Determine composer cache directory
|
||||
id: composer-cache
|
||||
run: "echo \"::set-output name=directory::$(composer config cache-dir)\""
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: "${{ steps.composer-cache.outputs.directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install
|
||||
|
||||
- name: Install LDAP package
|
||||
run: composer require laminas/laminas-ldap
|
||||
|
||||
- name: Setup problem matchers for PHPUnit
|
||||
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
|
||||
|
||||
- name: Run tests
|
||||
run: vendor/bin/phpunit tests/ --coverage-clover=coverage.xml
|
||||
env:
|
||||
@@ -40,6 +60,7 @@ jobs:
|
||||
APP_ENV: dev
|
||||
MAILER_URL: null://localhost
|
||||
TEST_WITH_BUNDLES: 1
|
||||
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
|
||||
21
.github/workflows/lockfiles.yaml
vendored
Normal file
21
.github/workflows/lockfiles.yaml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Check .lock files
|
||||
on:
|
||||
pull_request: null
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
lockfiles:
|
||||
runs-on: ubuntu-latest
|
||||
name: Verify lock file integrity
|
||||
steps:
|
||||
|
||||
- name: Clone Kimai
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Prevent file change
|
||||
uses: xalvarez/prevent-file-change-action@v1
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
pattern: .*.lock
|
||||
trustedAuthors: kevinpapst, dependabot
|
||||
22
.github/workflows/testing.yaml
vendored
22
.github/workflows/testing.yaml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0']
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
|
||||
name: Integration (${{ matrix.php }})
|
||||
steps:
|
||||
@@ -33,14 +33,27 @@ jobs:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
extensions: mbstring, xml, ctype, iconv, intl, mysql, zip, gd, ldap
|
||||
tools: cs2pr:1.1.0
|
||||
tools: cs2pr:1.1.0, symfony-cli
|
||||
|
||||
- name: Determine composer cache directory
|
||||
id: composer-cache
|
||||
run: "echo \"::set-output name=directory::$(composer config cache-dir)\""
|
||||
|
||||
- name: Cache Composer dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: "${{ steps.composer-cache.outputs.directory }}"
|
||||
key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ramsey/composer-install@v1
|
||||
run: composer install
|
||||
|
||||
- name: Validate Composer
|
||||
run: composer validate --strict
|
||||
|
||||
- name: Warmup cache for PHPStan
|
||||
run: APP_ENV=dev bin/console kimai:reload -n
|
||||
|
||||
- name: Check codestyles
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php-cs-fixer.dist.php --using-cache=no --show-progress=none --format=checkstyle | cs2pr
|
||||
|
||||
@@ -53,6 +66,9 @@ jobs:
|
||||
- name: Lint codebase
|
||||
run: composer linting
|
||||
|
||||
- name: Check for security issues in packages
|
||||
run: symfony security:check
|
||||
|
||||
- name: Install LDAP package
|
||||
run: composer require laminas/laminas-ldap
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
unreleased=true
|
||||
future-release=1.16
|
||||
future-release=1.19
|
||||
exclude-labels=duplicate,support,question,invalid,wontfix,release,waiting for feedback,documentation
|
||||
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt,documentation
|
||||
issues-wo-labels=false
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2019 Kevin Papst @ https://www.kevinpapst.de
|
||||
Copyright (c) 2017-2022 Kevin Papst @ https://www.kevinpapst.de
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
19
README.md
19
README.md
@@ -27,19 +27,20 @@ It is built with modern technologies such as [Symfony](https://github.com/symfon
|
||||
|
||||
### Requirements
|
||||
|
||||
- PHP 7.3 or higher (PHP 8 supported)
|
||||
- PHP 7.4, 8.0 or 8.1
|
||||
- MariaDB or MySQL
|
||||
- A webserver and subdomain
|
||||
- PHP extensions: `gd`, `intl`, `json`, `mbstring`, `pdo`, `xsl`, `zip`
|
||||
|
||||
### About
|
||||
|
||||
This is the new version of the open source time tracker Kimai. It is stable and production ready, ships
|
||||
The evolution of the most known(?) open source project time-tracker Kimai. It is stable, production ready and ships
|
||||
with many advanced features, including but not limited to:
|
||||
|
||||
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
|
||||
authentication via SAML/LDAP/Database, customizable role and team permissions, responsive and ready for your mobile device,
|
||||
user specific rates, advanced search & filtering, money and time budgets, reporting, support for plugins and many more.
|
||||
user/customer/project specific rates, advanced search & filtering, money and time budgets, reporting, support for plugins
|
||||
and so many more.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -63,9 +64,9 @@ user specific rates, advanced search & filtering, money and time budgets, report
|
||||
You can see a rough development roadmap in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
|
||||
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome.
|
||||
|
||||
> Kimai 2 uses a rolling release concept for delivering updates.
|
||||
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
|
||||
> The master branch is always deployable, release tags are only snapshots of the current development version.
|
||||
> Kimai uses a rolling release concept for delivering updates.
|
||||
> You don't have to wait for the next official release, upgrade it at any time from the master branch,
|
||||
> which is always deployable - release tags are simple snapshots of the development version.
|
||||
|
||||
Release versions will be created on a regular base (approx. one release every 2 months).
|
||||
Every code change, whether it's a new feature or a bugfix, will be done on the master branch.
|
||||
@@ -78,11 +79,11 @@ The best way to start is to [open a new issue](https://github.com/kevinpapst/kim
|
||||
|
||||
In case you want to contribute, but you wouldn't know how, here are some suggestions:
|
||||
|
||||
- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms lie Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every little bit helps!
|
||||
- Spread the word: More user means more people testing and contributing to Kimai - which in turn means better stability and more and better features. Please vote for Kimai on platforms like Slant, Product Hunt, Softpedia or AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every bit helps!
|
||||
- Answer questions: You know the answer to another user's problem? Share your knowledge!
|
||||
- Make a feature request: Something can be done better? Something essential missing? Let us know!
|
||||
- Report bugs
|
||||
- Contribute: You don't have to be programmer to help. The documentation and translation could use some love as well.
|
||||
- Sponsor the project
|
||||
- You don't have to be programmer to help. The documentation and translation could use some love as well.
|
||||
- Sponsor the project, free software still costs money
|
||||
|
||||
There is one simple rule in our "Code of conduct": Don't be an ass!
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
As announced in the [README](README.md) I only support the latest available release and master.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
|---------|--------------------|
|
||||
| master | :white_check_mark: |
|
||||
| 1.16 | :white_check_mark: |
|
||||
| < 1.16 | :x: |
|
||||
| 1.19 | :white_check_mark: |
|
||||
| < 1.19 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -20,5 +20,5 @@ Depending on the size of the required fixes, this might take a couple of hours o
|
||||
|
||||
You can expect that your message will be answered ASAP.
|
||||
|
||||
If your issue is valid and after I verified and fixed it, you will be mentioned in the release notes.
|
||||
You will be mentioned in the release notes if your issue is valid and after it was fixed.
|
||||
I am grateful for any (discrete) disclosure of vulnerabilities!
|
||||
|
||||
@@ -20,6 +20,7 @@ require('select2/dist/js/i18n/de');
|
||||
require('select2/dist/js/i18n/el');
|
||||
require('select2/dist/js/i18n/es');
|
||||
require('select2/dist/js/i18n/eu');
|
||||
require('select2/dist/js/i18n/fa');
|
||||
require('select2/dist/js/i18n/fi');
|
||||
require('select2/dist/js/i18n/fr');
|
||||
require('select2/dist/js/i18n/he');
|
||||
@@ -27,6 +28,7 @@ require('select2/dist/js/i18n/hu');
|
||||
require('select2/dist/js/i18n/it');
|
||||
require('select2/dist/js/i18n/ja');
|
||||
require('select2/dist/js/i18n/ko');
|
||||
require('select2/dist/js/i18n/nb');
|
||||
require('select2/dist/js/i18n/nl');
|
||||
require('select2/dist/js/i18n/pl');
|
||||
require('select2/dist/js/i18n/pt');
|
||||
@@ -51,6 +53,7 @@ require('moment/locale/el');
|
||||
require('moment/locale/eo');
|
||||
require('moment/locale/es');
|
||||
require('moment/locale/eu');
|
||||
require('moment/locale/fa');
|
||||
require('moment/locale/fi');
|
||||
require('moment/locale/fo');
|
||||
require('moment/locale/fr');
|
||||
@@ -59,6 +62,7 @@ require('moment/locale/hu');
|
||||
require('moment/locale/it');
|
||||
require('moment/locale/ja');
|
||||
require('moment/locale/ko');
|
||||
require('moment/locale/nb');
|
||||
require('moment/locale/nl');
|
||||
require('moment/locale/pl');
|
||||
require('moment/locale/pt');
|
||||
|
||||
@@ -14,6 +14,7 @@ require('fullcalendar/dist/locale/de-ch');
|
||||
require('fullcalendar/dist/locale/el');
|
||||
require('fullcalendar/dist/locale/es');
|
||||
require('fullcalendar/dist/locale/eu');
|
||||
require('fullcalendar/dist/locale/fa');
|
||||
require('fullcalendar/dist/locale/fi');
|
||||
require('fullcalendar/dist/locale/fr');
|
||||
require('fullcalendar/dist/locale/he');
|
||||
@@ -21,6 +22,7 @@ require('fullcalendar/dist/locale/hu');
|
||||
require('fullcalendar/dist/locale/it');
|
||||
require('fullcalendar/dist/locale/ja');
|
||||
require('fullcalendar/dist/locale/ko');
|
||||
require('fullcalendar/dist/locale/nb');
|
||||
require('fullcalendar/dist/locale/nl');
|
||||
require('fullcalendar/dist/locale/pl');
|
||||
require('fullcalendar/dist/locale/pt');
|
||||
|
||||
@@ -36,6 +36,7 @@ import KimaiDatePicker from "./plugins/KimaiDatePicker";
|
||||
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
|
||||
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
|
||||
import KimaiDateUtils from "./plugins/KimaiDateUtils";
|
||||
import KimaiEscape from "./plugins/KimaiEscape";
|
||||
|
||||
export default class KimaiLoader {
|
||||
|
||||
@@ -48,6 +49,7 @@ export default class KimaiLoader {
|
||||
new KimaiTranslation(translations)
|
||||
);
|
||||
|
||||
kimai.registerPlugin(new KimaiEscape());
|
||||
kimai.registerPlugin(new KimaiEvent());
|
||||
kimai.registerPlugin(new KimaiAPI());
|
||||
kimai.registerPlugin(new KimaiAlert());
|
||||
|
||||
@@ -42,7 +42,7 @@ export default class KimaiPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns null, if xou call it BEFORE init().
|
||||
* This function returns null, if you call it BEFORE init().
|
||||
*
|
||||
* @returns {KimaiContainer}
|
||||
*/
|
||||
@@ -66,4 +66,11 @@ export default class KimaiPlugin {
|
||||
return this.getContainer().getPlugin(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
escape(title) {
|
||||
return this.getPlugin('escape').escapeForHtml(title);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this.updateBrowserTitle) {
|
||||
document.title = document.querySelector('body').dataset['title'];
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this.updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
getFormattedDate(dateTime) {
|
||||
return moment(dateTime).format(this.getConfiguration('formatDate'));
|
||||
}
|
||||
|
||||
getWeekDaysShort() {
|
||||
return moment.localeData().weekdaysShort();
|
||||
}
|
||||
|
||||
35
assets/js/plugins/KimaiEscape.js
Normal file
35
assets/js/plugins/KimaiEscape.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiEscape: sanitize strings
|
||||
*/
|
||||
|
||||
import KimaiPlugin from "../KimaiPlugin";
|
||||
|
||||
export default class KimaiEscape extends KimaiPlugin {
|
||||
|
||||
getId() {
|
||||
return 'escape';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @returns {string}
|
||||
*/
|
||||
escapeForHtml(title) {
|
||||
const tagsToReplace = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
};
|
||||
|
||||
return title.replace(/[&<>]/g, function(tag) {
|
||||
return tagsToReplace[tag] || tag;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -33,46 +33,22 @@ export default class KimaiForm extends KimaiPlugin {
|
||||
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
|
||||
}
|
||||
|
||||
getFormData(form) {
|
||||
/**
|
||||
* @param {HTMLFormElement} form
|
||||
* @param {Object} overwrites
|
||||
* @returns {string}
|
||||
*/
|
||||
convertFormDataToQueryString(form, overwrites = {})
|
||||
{
|
||||
let serialized = [];
|
||||
let data = new FormData(form);
|
||||
|
||||
// Loop through each field in the form
|
||||
for (let i = 0; i < form.elements.length; i++) {
|
||||
|
||||
let field = form.elements[i];
|
||||
|
||||
// Don't serialize a couple of field types (button and submit are important to exclude, eg. invoice preview would fail otherwise)
|
||||
if (!field.name || field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If a multi-select, get all selections
|
||||
if (field.type === 'select-multiple') {
|
||||
for (var n = 0; n < field.options.length; n++) {
|
||||
if (!field.options[n].selected) {
|
||||
continue;
|
||||
}
|
||||
serialized.push({
|
||||
name: field.name,
|
||||
value: field.options[n].value
|
||||
});
|
||||
}
|
||||
} else if ((field.type !== 'checkbox' && field.type !== 'radio') || field.checked) {
|
||||
serialized.push({
|
||||
name: field.name,
|
||||
value: field.value
|
||||
});
|
||||
}
|
||||
for (const key in overwrites) {
|
||||
data.set(key, overwrites[key]);
|
||||
}
|
||||
|
||||
return serialized;
|
||||
}
|
||||
|
||||
convertFormDataToQueryString(formData) {
|
||||
let serialized = [];
|
||||
|
||||
for (let row of formData) {
|
||||
serialized.push(encodeURIComponent(row.name) + "=" + encodeURIComponent(row.value));
|
||||
for (let row of data) {
|
||||
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
|
||||
}
|
||||
|
||||
return serialized.join('&');
|
||||
|
||||
@@ -118,9 +118,13 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
options = {...options, ...{
|
||||
language: this.getConfiguration('locale').replace('_', '-'),
|
||||
theme: "bootstrap",
|
||||
matcher: this.matcher
|
||||
matcher: this.matcher,
|
||||
dropdownAutoWidth: true,
|
||||
width: "resolve"
|
||||
}};
|
||||
|
||||
const element = jQuery(node);
|
||||
|
||||
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
|
||||
const templateResultFunc = function (state) {
|
||||
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;"> </span>' + state.text + '</span>');
|
||||
@@ -131,10 +135,20 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
templateResult: templateResultFunc
|
||||
}};
|
||||
|
||||
jQuery(node).select2(colorOptions);
|
||||
element.select2(colorOptions);
|
||||
} else {
|
||||
jQuery(node).select2(options);
|
||||
element.select2(options);
|
||||
}
|
||||
|
||||
// this is a bugfix for safari, which does render the dropdown only with correct width upon the second opening
|
||||
// see https://github.com/select2/select2/issues/4678
|
||||
element.on('select2:open', function (ev) {
|
||||
if (element.data('performing-reopen') === undefined || element.data('performing-reopen') === null) {
|
||||
element.data('performing-reopen', true);
|
||||
element.select2('close');
|
||||
element.select2('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activateSelectPicker(selector, container) {
|
||||
@@ -161,18 +175,25 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
|
||||
let emptyOpts = [];
|
||||
let options = [];
|
||||
let titlePattern = null;
|
||||
if (select[0] !== undefined && select[0].dataset !== undefined && select[0].dataset['optionPattern'] !== undefined) {
|
||||
titlePattern = select[0].dataset['optionPattern'];
|
||||
}
|
||||
if (titlePattern === null || titlePattern === '') {
|
||||
titlePattern = '{name}';
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '__empty__') {
|
||||
for (const entity of value) {
|
||||
emptyOpts.push(this._createOption(entity.name, entity.id));
|
||||
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let optGroup = this._createOptgroup(key);
|
||||
for (const entity of value) {
|
||||
optGroup.appendChild(this._createOption(entity.name, entity.id));
|
||||
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
options.push(optGroup);
|
||||
}
|
||||
@@ -183,6 +204,23 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
// if available, re-select the previous selected option (mostly usable for global activities)
|
||||
select.val(selectedValue);
|
||||
|
||||
// pre-select an option if it is the only available one
|
||||
if (select.val() === '') {
|
||||
const allOptions = select.find('option');
|
||||
const optionLength = allOptions.length;
|
||||
let selectOption = '';
|
||||
|
||||
if (optionLength === 1) {
|
||||
selectOption = allOptions[0].value;
|
||||
} else if (optionLength === 2 && emptyOption.length === 1) {
|
||||
selectOption = allOptions[1].value;
|
||||
}
|
||||
|
||||
if (selectOption !== '') {
|
||||
select.val(selectOption);
|
||||
}
|
||||
}
|
||||
|
||||
// if we don't trigger the change, the other selects won't reset
|
||||
select.trigger('change');
|
||||
|
||||
@@ -192,6 +230,48 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {array} entity
|
||||
* @private
|
||||
*/
|
||||
_getTitleFromPattern(pattern, entity) {
|
||||
const DATE_UTILS = this.getPlugin('date');
|
||||
const regexp = new RegExp('{[^}]*?}','g');
|
||||
let title = pattern;
|
||||
let match = null;
|
||||
|
||||
while ((match = regexp.exec(pattern)) !== null) {
|
||||
const field = match[0].substr(1, match[0].length - 2);
|
||||
let value = entity[field] === undefined ? null : entity[field];
|
||||
if ((field === 'start' || field === 'end')) {
|
||||
if (value === null) {
|
||||
value = '?';
|
||||
} else {
|
||||
value = DATE_UTILS.getFormattedDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
|
||||
}
|
||||
title = title.replace(/- \?-\?/, '');
|
||||
title = title.replace(/\r\n|\r|\n/g, ' ');
|
||||
title = title.substr(0, 110);
|
||||
|
||||
const chars = '- ';
|
||||
let start = 0, end = title.length;
|
||||
|
||||
while (start < end && chars.indexOf(title[start]) >= 0) {
|
||||
++start;
|
||||
}
|
||||
|
||||
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
|
||||
@@ -65,9 +65,10 @@ export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
|
||||
for (let timesheet of entries) {
|
||||
let label = this.attributes['template']
|
||||
.replace('%customer%', timesheet.project.customer.name)
|
||||
.replace('%project%', timesheet.project.name)
|
||||
.replace('%activity%', timesheet.activity.name);
|
||||
.replace('%customer%', this.escape(timesheet.project.customer.name))
|
||||
.replace('%project%', this.escape(timesheet.project.name))
|
||||
.replace('%activity%', this.escape(timesheet.activity.name))
|
||||
;
|
||||
|
||||
htmlToInsert +=
|
||||
`<li>` +
|
||||
|
||||
@@ -142,7 +142,9 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
ordered[key] = options[key];
|
||||
});
|
||||
|
||||
this.getContainer().getPlugin('form-select').updateOptions(selectName, ordered);
|
||||
/** @var {KimaiFormSelect} select */
|
||||
const select = this.getContainer().getPlugin('form-select');
|
||||
select.updateOptions(selectName, ordered);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -42,15 +42,6 @@ td {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.box-profile {
|
||||
img.img-circle {
|
||||
width: 100px;
|
||||
}
|
||||
.profile-username {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.label-gray {
|
||||
background-color: $gray-lte;
|
||||
color: #666;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
table.dataTable.table > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
.img-circle {
|
||||
max-width: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-right: 0;
|
||||
&.teamlead {
|
||||
box-shadow: 0 0 5px 3px $gray-lte;
|
||||
|
||||
@@ -52,4 +52,9 @@ form.form-narrow {
|
||||
.checkbox-menu li.active label:hover,
|
||||
.checkbox-menu li.active label:focus {
|
||||
background-color: #b8b8ff;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
min-width:50px;
|
||||
padding: 6px;
|
||||
}
|
||||
@@ -34,6 +34,9 @@ p {
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -77,7 +80,7 @@ table.footer {
|
||||
line-height: 14px;
|
||||
}
|
||||
table.footer td {
|
||||
vertical-align: bottom;
|
||||
vertical-align: top;
|
||||
}
|
||||
/* The invoice items list */
|
||||
table.items {
|
||||
|
||||
@@ -19,280 +19,287 @@
|
||||
@import "~bootstrap-sass/assets/stylesheets/bootstrap/utilities";
|
||||
@import "~bootstrap-sass/assets/stylesheets/bootstrap/responsive-utilities";
|
||||
|
||||
body.invoice_print {
|
||||
background-color: #eee;
|
||||
body {
|
||||
font-family: $font-family-sans-serif;
|
||||
|
||||
&.invoice_print {
|
||||
background-color: #eee;
|
||||
|
||||
.table.no-border, .table.no-border td, .table.no-border th {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
}
|
||||
|
||||
.invoice {
|
||||
margin: 105px auto 30px auto;
|
||||
padding: 50px 65px;
|
||||
min-height: 297mm;
|
||||
position: relative;
|
||||
max-width: 210mm;
|
||||
box-shadow: 0 0 20px rgba(80,80,80,0.7);
|
||||
background: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin: 10px 0 20px 0;
|
||||
font-size: 22px;
|
||||
>small {
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
.table.no-border, .table.no-border td, .table.no-border th {
|
||||
border: 0;
|
||||
}
|
||||
h3 {
|
||||
border-color: #ddd;
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: $font-family-sans-serif;
|
||||
}
|
||||
}
|
||||
/*
|
||||
.page-header {
|
||||
margin: 10px 0 20px 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
*/
|
||||
div.invoice-address {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
table.invoice-meta th {
|
||||
padding-right: 40px;
|
||||
}
|
||||
table.invoice-sum th {
|
||||
width: 70%;
|
||||
}
|
||||
table.invoice-sum th, table.invoice-sum td {
|
||||
text-align: right;
|
||||
}
|
||||
.invoice {
|
||||
margin: 105px auto 30px auto;
|
||||
padding: 50px 65px;
|
||||
min-height: 297mm;
|
||||
position: relative;
|
||||
max-width: 210mm;
|
||||
box-shadow: 0 0 20px rgba(80, 80, 80, 0.7);
|
||||
background: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.invoice-items {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 3em;
|
||||
.page-header {
|
||||
margin: 10px 0 20px 0;
|
||||
font-size: 22px;
|
||||
|
||||
.table {
|
||||
thead th {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 15px
|
||||
> small {
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
}
|
||||
tfoot {
|
||||
border-top: 1px solid #ddd;
|
||||
|
||||
tr:first-child th,
|
||||
tr:first-child td {
|
||||
padding-top: 15px
|
||||
h3 {
|
||||
border-color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
div.invoice-address {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
table.invoice-meta th {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
table.invoice-sum th {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
table.invoice-sum th, table.invoice-sum td {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.invoice-items {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 3em;
|
||||
|
||||
.table {
|
||||
thead th {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
tr {
|
||||
th,td {
|
||||
border:none;
|
||||
padding: 4px 8px;
|
||||
|
||||
tfoot {
|
||||
border-top: 1px solid #ddd;
|
||||
|
||||
tr:first-child th,
|
||||
tr:first-child td {
|
||||
padding-top: 15px
|
||||
}
|
||||
|
||||
tr {
|
||||
th, td {
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 50px;
|
||||
font-size: 0.8em;
|
||||
padding-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ================================ FREELANCER INVOICE ================================ */
|
||||
|
||||
#freelancer-invoice {
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
h1 {
|
||||
font: bold 100% sans-serif;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: bold 1.5em sans-serif;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* table */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* header */
|
||||
|
||||
header {
|
||||
&:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
address {
|
||||
float: left;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
margin: 0 1em 1em 0;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
article address {
|
||||
font-size: 1em;
|
||||
float: left;
|
||||
}
|
||||
|
||||
article.address {
|
||||
margin:1em 0 1.5em 0;
|
||||
}
|
||||
|
||||
/* article */
|
||||
|
||||
article:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
clip: rect(0 0 0 0);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* table meta & balance */
|
||||
|
||||
table.meta:after, table.balance:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
/* table meta */
|
||||
|
||||
table.meta {
|
||||
float: right;
|
||||
width: 50%;
|
||||
font-size: 85%;
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* table balance */
|
||||
|
||||
table.balance {
|
||||
float: right;
|
||||
margin-top: 1em;
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* table items */
|
||||
|
||||
table.inventory {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
margin-top: 2em;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
table.inventory th,
|
||||
table.inventory td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
table.inventory thead th {
|
||||
font-weight: bold;
|
||||
border-width: 0 0 1px 0;
|
||||
border-bottom: 1px solid #000;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:first-child td {
|
||||
padding-top: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:last-child td {
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-color: #000;
|
||||
border-top: 1px solid #ccc;
|
||||
margin-top: 50px;
|
||||
font-size: 0.8em;
|
||||
padding-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================ PRINTING RULES ================================ */
|
||||
/* ================================ FREELANCER INVOICE ================================ */
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
body, .wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
}
|
||||
#freelancer-invoice {
|
||||
span:empty {
|
||||
display: none;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
h1 {
|
||||
font: bold 100% sans-serif;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.add, .cut {
|
||||
display: none;
|
||||
|
||||
h2 {
|
||||
font: bold 1.5em sans-serif;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* table */
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* header */
|
||||
|
||||
header {
|
||||
&:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
address {
|
||||
float: left;
|
||||
font-style: normal;
|
||||
line-height: 1.25;
|
||||
margin: 0 1em 1em 0;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
article address {
|
||||
font-size: 1em;
|
||||
float: left;
|
||||
}
|
||||
|
||||
article.address {
|
||||
margin: 1em 0 1.5em 0;
|
||||
}
|
||||
|
||||
/* article */
|
||||
|
||||
article:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
article h1 {
|
||||
clip: rect(0 0 0 0);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
article p {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* table meta & balance */
|
||||
|
||||
table.meta:after, table.balance:after {
|
||||
clear: both;
|
||||
content: "";
|
||||
display: table;
|
||||
}
|
||||
|
||||
/* table meta */
|
||||
|
||||
table.meta {
|
||||
float: right;
|
||||
width: 50%;
|
||||
font-size: 85%;
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
/* table balance */
|
||||
|
||||
table.balance {
|
||||
float: right;
|
||||
margin-top: 1em;
|
||||
|
||||
th {
|
||||
text-align: right;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* table items */
|
||||
|
||||
table.inventory {
|
||||
clear: both;
|
||||
width: 100%;
|
||||
margin-top: 2em;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
table.inventory th,
|
||||
table.inventory td {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
table.inventory thead th {
|
||||
font-weight: bold;
|
||||
border-width: 0 0 1px 0;
|
||||
border-bottom: 1px solid #000;
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:first-child td {
|
||||
padding-top: 15px
|
||||
}
|
||||
|
||||
table.inventory tbody tr:last-child td {
|
||||
padding-bottom: 15px
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
.invoice {
|
||||
margin: 1em 2em;
|
||||
font-size: 90%;
|
||||
width: unset;
|
||||
padding: 0;
|
||||
min-height: unset;
|
||||
position: unset;
|
||||
max-width: unset;
|
||||
box-shadow: unset;
|
||||
|
||||
/* ================================ PRINTING RULES ================================ */
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
body, .wrapper {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: white;
|
||||
}
|
||||
#freelancer-invoice {
|
||||
span:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add, .cut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.invoice {
|
||||
margin: 1em 2em;
|
||||
font-size: 90%;
|
||||
width: unset;
|
||||
padding: 0;
|
||||
min-height: unset;
|
||||
position: unset;
|
||||
max-width: unset;
|
||||
box-shadow: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,6 @@
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
img.img-circle {
|
||||
max-height: 26px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.avatar {
|
||||
max-height: 30px;
|
||||
max-width: 30px;
|
||||
|
||||
@@ -20,4 +20,10 @@
|
||||
/* empty <option> were too small to be noticed as clickable element */
|
||||
.select2-container--bootstrap .select2-results__option {
|
||||
min-height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fixes the size on quick-entry form */
|
||||
.select2-container--bootstrap {
|
||||
display: table;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -5,28 +5,6 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/* UserPanel is deactivated right now */
|
||||
/*
|
||||
.user-panel>.info {
|
||||
left: 80px;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
|
||||
/*
|
||||
.control-sidebar {
|
||||
select {
|
||||
color: $input-color;
|
||||
background-color: $input-bg;
|
||||
}
|
||||
div.image {
|
||||
img.img-circle {
|
||||
max-width: 34px;
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
.sidebar-mini.sidebar-collapse .sidebar-menu li.header {
|
||||
border-top-width: 1px;
|
||||
@@ -39,13 +17,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Simplify focus detection, when using keyboard tab navigation
|
||||
Deactivated, as it looks weird when the link is clicked
|
||||
*/
|
||||
/*
|
||||
.sidebar-menu > li > a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
*/
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.3",
|
||||
"php": "7.3.*||7.4.*||8.0.*",
|
||||
"ext-gd": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
@@ -26,7 +26,7 @@
|
||||
"doctrine/orm": "^2.8",
|
||||
"erusev/parsedown": "^1.6",
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"handcraftedinthealps/rest-routing-bundle": "^1.0",
|
||||
"jms/metadata": "^2.0",
|
||||
"jms/serializer-bundle": "^3.9",
|
||||
@@ -78,12 +78,12 @@
|
||||
"dama/doctrine-test-bundle": "^6.0",
|
||||
"doctrine/doctrine-fixtures-bundle": "^3.2",
|
||||
"fakerphp/faker": "^1.15",
|
||||
"friendsofphp/php-cs-fixer": "^3.0",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-doctrine": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpstan/phpstan-symfony": "^1.0",
|
||||
"phpunit/phpunit": "^8.0",
|
||||
"phpunit/phpunit": "^9.0",
|
||||
"symfony/browser-kit": "^4.4",
|
||||
"symfony/css-selector": "^4.4",
|
||||
"symfony/debug-bundle": "^4.4",
|
||||
@@ -107,7 +107,11 @@
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"sort-packages": true
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"composer/package-versions-deprecated": true,
|
||||
"symfony/flex": true
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
3241
composer.lock
generated
3241
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,7 @@ kimai:
|
||||
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
|
||||
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
|
||||
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
|
||||
BILLABLE: ['edit_billable_own_timesheet','edit_billable_other_timesheet']
|
||||
TEAMS: ['view_team','create_team','edit_team','delete_team']
|
||||
LOCKDOWN: ['lockdown_grace_timesheet','lockdown_override_timesheet']
|
||||
REPORTING: ['view_reporting','view_other_reporting']
|
||||
@@ -111,9 +112,9 @@ kimai:
|
||||
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data']
|
||||
# link above sets to one complete set for each user role
|
||||
ROLE_USER: ['@TIMESHEET','@PROFILE','@REPORTING','@SINGLE_USER']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@BILLABLE','@TAGS','@REPORTING','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@BILLABLE','@TAGS','@LOCKDOWN','@REPORTING','@SINGLE_SUPER_ADMIN']
|
||||
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
|
||||
maps:
|
||||
ROLE_USER: ['ROLE_USER']
|
||||
|
||||
@@ -9,6 +9,7 @@ monolog:
|
||||
- ^/
|
||||
nested:
|
||||
type: stream
|
||||
level: info
|
||||
path: "%kernel.logs_dir%/%kernel.environment%.log"
|
||||
|
||||
console:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
|
||||
parameters:
|
||||
locale: en
|
||||
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|eo|es|eu|fi|fo|fr|he|hr|hu|it|ja|ko|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
|
||||
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"jquery-ui": "^1.12",
|
||||
"js-cookie": "^2.2",
|
||||
"moment": "^2.29",
|
||||
"node-sass": "^4.14",
|
||||
"sass-loader": "^10.0",
|
||||
"node-sass": "^7.0",
|
||||
"sass-loader": "^11.0",
|
||||
"select2": "^4.0",
|
||||
"select2-bootstrap-theme": "^0.1.0-beta.10",
|
||||
"sweetalert2": "^10.16",
|
||||
|
||||
15
phpstan.neon
15
phpstan.neon
@@ -1,8 +1,9 @@
|
||||
includes:
|
||||
- vendor/phpstan/phpstan-symfony/extension.neon
|
||||
- vendor/phpstan/phpstan-doctrine/extension.neon
|
||||
- vendor/phpstan/phpstan-symfony/rules.neon
|
||||
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
|
||||
- %rootDir%/../phpstan-symfony/extension.neon
|
||||
- %rootDir%/../phpstan-symfony/rules.neon
|
||||
- %rootDir%/../phpstan-doctrine/extension.neon
|
||||
- %rootDir%/../phpstan-doctrine/rules.neon
|
||||
- %rootDir%/../phpstan/conf/bleedingEdge.neon
|
||||
|
||||
parameters:
|
||||
tmpDir: %rootDir%/../../../var/cache/phpstan
|
||||
@@ -11,4 +12,8 @@ parameters:
|
||||
excludePaths:
|
||||
- %rootDir%/../../../src/Ldap/LdapDriver.php
|
||||
treatPhpDocTypesAsCertain: false
|
||||
inferPrivatePropertyTypeFromConstructor: true
|
||||
inferPrivatePropertyTypeFromConstructor: true
|
||||
doctrine:
|
||||
objectManagerLoader: %rootDir%/../../../tests/phpstan-doctrine.php
|
||||
symfony:
|
||||
containerXmlPath: %rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<!-- https://phpunit.de/manual/current/en/appendixes.configuration.html -->
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/6.1/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
bootstrap="tests/bootstrap.php"
|
||||
>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd" backupGlobals="false" colors="true" bootstrap="tests/bootstrap.php">
|
||||
<php>
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<ini name="max_execution_time" value="-1" />
|
||||
<ini name="intl.default_locale" value="en_US" />
|
||||
<ini name="error_reporting" value="-1"/>
|
||||
<ini name="max_execution_time" value="-1"/>
|
||||
<ini name="intl.default_locale" value="en_US"/>
|
||||
<env name="KERNEL_CLASS" value="App\Kernel" force="true"/>
|
||||
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
|
||||
<env name="APP_ENV" value="test" force="true"/>
|
||||
<env name="APP_DEBUG" value="1" force="true"/>
|
||||
<env name="APP_SECRET" value="5a79a1c866efef9ca1800f971d689f3e" force="true"/>
|
||||
<!--
|
||||
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
|
||||
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
|
||||
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
|
||||
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
|
||||
CREATE DATABASE IF NOT EXISTS `kimai2_test`;
|
||||
CREATE USER IF NOT EXISTS `kimai2_test`@127.0.0.1;
|
||||
ALTER USER `kimai2_test`@127.0.0.1 IDENTIFIED BY "kimai2_test";
|
||||
GRANT execute,select,insert,update,delete,create,alter,drop,index,references ON `kimai2_test`.* TO kimai2_test@127.0.0.1;
|
||||
-->
|
||||
<env name="DATABASE_URL" value="mysql://kimai2_test:kimai2_test@127.0.0.1:3306/kimai2_test"/>
|
||||
<env name="CORS_ALLOW_ORIGIN" value="^https?://localhost(:[0-9]+)?$"/>
|
||||
<env name="MAILER_URL" value="null://null"/>
|
||||
<env name="MAILER_FROM" value="kimai@example.com"/>
|
||||
|
||||
<!--
|
||||
REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE!
|
||||
|
||||
@@ -44,35 +37,34 @@
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<filter>
|
||||
<whitelist processUncoveredFilesFromWhitelist="true">
|
||||
<coverage processUncoveredFiles="true">
|
||||
<include>
|
||||
<directory suffix=".php">src/</directory>
|
||||
<directory suffix=".php">templates/</directory>
|
||||
<exclude>
|
||||
<directory suffix=".php">src/Migrations/</directory>
|
||||
<directory suffix=".php">assets/</directory>
|
||||
<directory suffix=".php">bin/</directory>
|
||||
<directory suffix=".php">config/</directory>
|
||||
<directory suffix=".php">node_modules/</directory>
|
||||
<directory suffix=".php">public/</directory>
|
||||
<directory suffix=".php">tests/</directory>
|
||||
<directory suffix=".php">translations/</directory>
|
||||
<directory suffix=".php">var/</directory>
|
||||
<directory suffix=".php">vendor/</directory>
|
||||
</exclude>
|
||||
</whitelist>
|
||||
</filter>
|
||||
</include>
|
||||
<exclude>
|
||||
<directory suffix=".php">src/Migrations/</directory>
|
||||
<directory suffix=".php">assets/</directory>
|
||||
<directory suffix=".php">bin/</directory>
|
||||
<directory suffix=".php">config/</directory>
|
||||
<directory suffix=".php">node_modules/</directory>
|
||||
<directory suffix=".php">public/</directory>
|
||||
<directory suffix=".php">tests/</directory>
|
||||
<directory suffix=".php">translations/</directory>
|
||||
<directory suffix=".php">var/</directory>
|
||||
<directory suffix=".php">vendor/</directory>
|
||||
</exclude>
|
||||
</coverage>
|
||||
|
||||
<!--
|
||||
begins a database transaction before every testcase and rolls it back after the test finished, so
|
||||
tests can manipulate the database without affecting other tests
|
||||
@see https://github.com/dmaicher/doctrine-test-bundle
|
||||
wrap tests inside database transactions, so tests can safely manipulate contents without
|
||||
affecting other tests. @see https://github.com/dmaicher/doctrine-test-bundle
|
||||
-->
|
||||
<extensions>
|
||||
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension" />
|
||||
<extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
|
||||
</extensions>
|
||||
|
||||
<listeners>
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener" />
|
||||
<listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
|
||||
</listeners>
|
||||
</phpunit>
|
||||
</phpunit>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
public/build/app.a8c84f0c.js
Normal file
2
public/build/app.a8c84f0c.js
Normal file
File diff suppressed because one or more lines are too long
@@ -91,6 +91,10 @@
|
||||
* [KIMAI] KimaiDateUtils: responsible for handling date specific tasks
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiEscape: sanitize strings
|
||||
*/
|
||||
|
||||
/*!
|
||||
* [KIMAI] KimaiEvent: helper to trigger events
|
||||
*/
|
||||
File diff suppressed because one or more lines are too long
2
public/build/calendar.ba553253.js
Normal file
2
public/build/calendar.ba553253.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/app.3947eaef.js"
|
||||
"build/app.a8c84f0c.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.3bc2b4d9.css"
|
||||
"build/app.7beab68a.css"
|
||||
]
|
||||
},
|
||||
"invoice": {
|
||||
@@ -15,7 +15,7 @@
|
||||
"build/invoice.19f36eca.js"
|
||||
],
|
||||
"css": [
|
||||
"build/invoice.ff32661a.css"
|
||||
"build/invoice.ccdecd42.css"
|
||||
]
|
||||
},
|
||||
"invoice-pdf": {
|
||||
@@ -24,7 +24,7 @@
|
||||
"build/invoice-pdf.0ce1f3ce.js"
|
||||
],
|
||||
"css": [
|
||||
"build/invoice-pdf.9a7468ef.css"
|
||||
"build/invoice-pdf.e73a6dda.css"
|
||||
]
|
||||
},
|
||||
"chart": {
|
||||
@@ -36,7 +36,7 @@
|
||||
"calendar": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/calendar.52d75f80.js"
|
||||
"build/calendar.ba553253.js"
|
||||
],
|
||||
"css": [
|
||||
"build/calendar.25e7e872.css"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
body{font-family:sans-serif;font-size:10pt}body,h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{color:#666;font-size:80%;text-align:right;padding-top:6px}.wrapper{margin:4px}table{width:100%;border-spacing:0}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{padding-bottom:4px;border-bottom:1px solid #999}table.header .title{font-weight:400;font-size:140%}table.footer{border-top:1px solid #999;font-size:80%;padding-top:8px;line-height:14px}table.footer td{vertical-align:bottom}table.items{border-collapse:collapse;margin-top:50px;margin-bottom:60px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{padding:20px 0 0;line-height:1px}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}
|
||||
1
public/build/invoice-pdf.e73a6dda.css
Normal file
1
public/build/invoice-pdf.e73a6dda.css
Normal file
@@ -0,0 +1 @@
|
||||
body{font-family:sans-serif;font-size:10pt}body,h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{color:#666;font-size:80%;text-align:right;padding-top:6px}.wrapper{margin:4px}table{width:100%;border-spacing:0}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{padding-bottom:4px;border-bottom:1px solid #999}table.header .title{font-weight:400;font-size:140%}table.footer{border-top:1px solid #999;font-size:80%;padding-top:8px;line-height:14px}table.footer td{vertical-align:top}table.items{border-collapse:collapse;margin-top:50px;margin-bottom:60px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{padding:20px 0 0;line-height:1px}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}
|
||||
1
public/build/invoice.ccdecd42.css
Normal file
1
public/build/invoice.ccdecd42.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"build/app.css": "build/app.3bc2b4d9.css",
|
||||
"build/app.js": "build/app.3947eaef.js",
|
||||
"build/invoice.css": "build/invoice.ff32661a.css",
|
||||
"build/app.css": "build/app.7beab68a.css",
|
||||
"build/app.js": "build/app.a8c84f0c.js",
|
||||
"build/invoice.css": "build/invoice.ccdecd42.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.e73a6dda.css",
|
||||
"build/invoice-pdf.js": "build/invoice-pdf.0ce1f3ce.js",
|
||||
"build/chart.js": "build/chart.e845e930.js",
|
||||
"build/calendar.css": "build/calendar.25e7e872.css",
|
||||
"build/calendar.js": "build/calendar.52d75f80.js",
|
||||
"build/calendar.js": "build/calendar.ba553253.js",
|
||||
"build/runtime.js": "build/runtime.b8e7bb04.js",
|
||||
"build/images/fa-solid-900.svg": "build/images/fa-solid-900.13de59f1.svg",
|
||||
"build/images/fa-brands-400.svg": "build/images/fa-brands-400.216edb96.svg",
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Form\API\TeamApiEditForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
@@ -201,7 +200,7 @@ final class TeamController extends BaseApiController
|
||||
* Update an existing team
|
||||
*
|
||||
* @SWG\Patch(
|
||||
* description="Update an existing team, you can pass all or just a subset of all attributes (passing users will replace all existing ones)",
|
||||
* description="Update an existing team, you can pass all or just a subset of all attributes (passing members will replace all existing ones)",
|
||||
* @SWG\Response(
|
||||
* response=200,
|
||||
* description="Returns the updated team",
|
||||
@@ -235,11 +234,12 @@ final class TeamController extends BaseApiController
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
// cache the current memberlist
|
||||
/** @var TeamMember[] $originalMembers */
|
||||
$originalMembers = [];
|
||||
foreach ($team->getMembers() as $member) {
|
||||
$originalMembers[] = $member;
|
||||
if ($request->request->has('members')) {
|
||||
foreach ($team->getMembers() as $member) {
|
||||
$team->removeMember($member);
|
||||
$this->repository->removeTeamMember($member);
|
||||
}
|
||||
$this->repository->saveTeam($team);
|
||||
}
|
||||
|
||||
$form = $this->createForm(TeamApiEditForm::class, $team);
|
||||
@@ -254,14 +254,6 @@ final class TeamController extends BaseApiController
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
|
||||
// and now remove the ones, which are not in the list any longer
|
||||
foreach ($originalMembers as $member) {
|
||||
if (!$team->hasMember($member)) {
|
||||
$member->getUser()->removeMembership($member);
|
||||
$this->repository->removeTeamMember($member);
|
||||
}
|
||||
}
|
||||
|
||||
$this->repository->saveTeam($team);
|
||||
|
||||
$view = new View($team, Response::HTTP_OK);
|
||||
|
||||
@@ -36,6 +36,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Swagger\Annotations as SWG;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Validator\Constraints;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
@@ -122,6 +123,7 @@ class TimesheetController extends BaseApiController
|
||||
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records before this date will be included (format: HTML5)")
|
||||
* @Rest\QueryParam(name="exported", requirements="0|1", strict=true, nullable=true, description="Use this flag if you want to filter for export state. Allowed values: 0=not exported, 1=exported (default: all)")
|
||||
* @Rest\QueryParam(name="active", requirements="0|1", strict=true, nullable=true, description="Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)")
|
||||
* @Rest\QueryParam(name="billable", requirements="0|1", strict=true, nullable=true, description="Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)")
|
||||
* @Rest\QueryParam(name="full", requirements="true", strict=true, nullable=true, description="Allows to fetch fully serialized objects including subresources. Allowed values: true (default: false)")
|
||||
* @Rest\QueryParam(name="term", description="Free search term")
|
||||
* @Rest\QueryParam(name="modified_after", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.")
|
||||
@@ -218,6 +220,15 @@ class TimesheetController extends BaseApiController
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($billable = $paramFetcher->get('billable'))) {
|
||||
$billable = (int) $billable;
|
||||
if ($billable === 1) {
|
||||
$query->setBillable(true);
|
||||
} elseif ($billable === 0) {
|
||||
$query->setBillable(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($exported = $paramFetcher->get('exported'))) {
|
||||
$exported = (int) $exported;
|
||||
if ($exported === 1) {
|
||||
@@ -316,6 +327,7 @@ class TimesheetController extends BaseApiController
|
||||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_billable' => $this->isGranted('edit_billable', $timesheet),
|
||||
'include_user' => $this->isGranted('create_other_timesheet'),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
@@ -393,6 +405,7 @@ class TimesheetController extends BaseApiController
|
||||
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
|
||||
'include_rate' => $this->isGranted('edit_rate', $timesheet),
|
||||
'include_exported' => $this->isGranted('edit_export', $timesheet),
|
||||
'include_billable' => $this->isGranted('edit_billable', $timesheet),
|
||||
'include_user' => $this->isGranted('edit', $timesheet),
|
||||
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
|
||||
@@ -613,12 +626,10 @@ class TimesheetController extends BaseApiController
|
||||
@trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED);
|
||||
}
|
||||
|
||||
$copyTimesheet
|
||||
->setHourlyRate($timesheet->getHourlyRate())
|
||||
->setFixedRate($timesheet->getFixedRate())
|
||||
->setDescription($timesheet->getDescription())
|
||||
->setBillable($timesheet->isBillable())
|
||||
;
|
||||
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
|
||||
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
|
||||
$copyTimesheet->setDescription($timesheet->getDescription());
|
||||
$copyTimesheet->setBillable($timesheet->isBillable());
|
||||
|
||||
foreach ($timesheet->getTags() as $tag) {
|
||||
$copyTimesheet->addTag($tag);
|
||||
@@ -704,6 +715,11 @@ class TimesheetController extends BaseApiController
|
||||
public function exportAction(Timesheet $id): Response
|
||||
{
|
||||
$timesheet = $id;
|
||||
|
||||
if ($timesheet->isExported() && !$this->isGranted('edit_exported_timesheet')) {
|
||||
throw new AccessDeniedHttpException('User cannot edit an exported timesheet');
|
||||
}
|
||||
|
||||
$timesheet->setExported(!$timesheet->isExported());
|
||||
|
||||
$this->service->updateTimesheet($timesheet);
|
||||
|
||||
@@ -114,6 +114,7 @@ final class CalendarService
|
||||
'timeframeBegin' => $this->configuration->getCalendarTimeframeBegin(),
|
||||
'timeframeEnd' => $this->configuration->getCalendarTimeframeEnd(),
|
||||
'dragDropAmount' => $this->configuration->getCalendarDragAndDropMaxEntries(),
|
||||
'entryTitlePattern' => $this->configuration->find('calendar.title_pattern'),
|
||||
];
|
||||
|
||||
$event = new CalendarConfigurationEvent($config);
|
||||
|
||||
40
src/Command/AbstractUserCommand.php
Normal file
40
src/Command/AbstractUserCommand.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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\Command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
abstract class AbstractUserCommand extends Command
|
||||
{
|
||||
protected function askForPassword(InputInterface $input, OutputInterface $output): string
|
||||
{
|
||||
/** @var QuestionHelper $helper */
|
||||
$helper = $this->getHelper('question');
|
||||
|
||||
$passwordQuestion = new Question('Please enter the password: ');
|
||||
$passwordQuestion->setHidden(true);
|
||||
$passwordQuestion->setHiddenFallback(false);
|
||||
$passwordQuestion->setValidator(function (?string $value) {
|
||||
$password = trim($value);
|
||||
if (empty($password)) {
|
||||
throw new \Exception('The password may not be empty');
|
||||
}
|
||||
|
||||
return $value;
|
||||
});
|
||||
$passwordQuestion->setMaxAttempts(3);
|
||||
|
||||
return $helper->ask($input, $output, $passwordQuestion);
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,11 @@ namespace App\Command;
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class ChangePasswordCommand extends Command
|
||||
final class ChangePasswordCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
@@ -35,7 +34,7 @@ class ChangePasswordCommand extends Command
|
||||
->setDescription('Change the password of a user.')
|
||||
->setDefinition([
|
||||
new InputArgument('username', InputArgument::REQUIRED, 'The username'),
|
||||
new InputArgument('password', InputArgument::REQUIRED, 'The password'),
|
||||
new InputArgument('password', InputArgument::OPTIONAL, 'The password'),
|
||||
])
|
||||
->setHelp(
|
||||
<<<'EOT'
|
||||
@@ -59,7 +58,12 @@ EOT
|
||||
protected function execute(InputInterface $input, OutputInterface $output)
|
||||
{
|
||||
$username = $input->getArgument('username');
|
||||
$password = $input->getArgument('password');
|
||||
|
||||
if (null !== $input->getArgument('password')) {
|
||||
$password = $input->getArgument('password');
|
||||
} else {
|
||||
$password = $this->askForPassword($input, $output);
|
||||
}
|
||||
|
||||
$user = $this->userService->findUserByUsernameOrThrowException($username);
|
||||
|
||||
|
||||
@@ -13,14 +13,11 @@ use App\Entity\User;
|
||||
use App\User\UserService;
|
||||
use App\Utils\CommandStyle;
|
||||
use App\Validator\ValidationFailedException;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
final class CreateUserCommand extends Command
|
||||
final class CreateUserCommand extends AbstractUserCommand
|
||||
{
|
||||
private $userService;
|
||||
|
||||
@@ -91,31 +88,4 @@ final class CreateUserCommand extends Command
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InputInterface $input
|
||||
* @param OutputInterface $output
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function askForPassword(InputInterface $input, OutputInterface $output): string
|
||||
{
|
||||
/* @var QuestionHelper $helper */
|
||||
$helper = $this->getHelper('question');
|
||||
|
||||
$passwordQuestion = new Question('Please enter the password: ');
|
||||
$passwordQuestion->setHidden(true);
|
||||
$passwordQuestion->setHiddenFallback(false);
|
||||
$passwordQuestion->setValidator(function (?string $value) {
|
||||
$password = trim($value);
|
||||
if (empty($password)) {
|
||||
throw new \Exception('The password may not be empty');
|
||||
}
|
||||
|
||||
return $value;
|
||||
});
|
||||
$passwordQuestion->setMaxAttempts(3);
|
||||
|
||||
return $helper->ask($input, $output, $passwordQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +337,10 @@ class InvoiceCreateCommand extends Command
|
||||
return $invoices;
|
||||
}
|
||||
|
||||
private function saveInvoicePreview(Response $response)
|
||||
private function saveInvoicePreview(Response $response): string
|
||||
{
|
||||
$filename = uniqid('invoice_');
|
||||
$directory = rtrim($this->previewDirectory, '/') . '/';
|
||||
|
||||
if ($response->headers->has('Content-Disposition')) {
|
||||
$disposition = $response->headers->get('Content-Disposition');
|
||||
@@ -360,12 +361,12 @@ class InvoiceCreateCommand extends Command
|
||||
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
$file = $response->getFile();
|
||||
$file->move($this->previewDirectory, $filename);
|
||||
$file->move($directory, $filename);
|
||||
} else {
|
||||
(new Filesystem())->dumpFile($this->previewDirectory . $filename, $response->getContent());
|
||||
(new Filesystem())->dumpFile($directory . $filename, $response->getContent());
|
||||
}
|
||||
|
||||
return $this->previewDirectory . $filename;
|
||||
return $directory . $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
48
src/Command/TimesheetStopAllCommand.php
Normal file
48
src/Command/TimesheetStopAllCommand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Timesheet\TimesheetService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
/**
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TimesheetStopAllCommand extends Command
|
||||
{
|
||||
private $timesheetService;
|
||||
|
||||
public function __construct(TimesheetService $timesheetService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->timesheetService = $timesheetService;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('kimai:timesheet:stop-all');
|
||||
$this->setDescription('Stop all running timesheets immediately');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$amount = $this->timesheetService->stopAll();
|
||||
|
||||
if (!$output->isQuiet()) {
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$io->success(sprintf('Stopped %s timesheet records.', $amount));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
470
src/Command/TranslationCommand.php
Normal file
470
src/Command/TranslationCommand.php
Normal file
@@ -0,0 +1,470 @@
|
||||
<?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\Command;
|
||||
|
||||
use App\Kernel;
|
||||
use App\Utils\LanguageService;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\HttpClient\HttpClient;
|
||||
|
||||
/**
|
||||
* Command used to execute all the basic application bootstrapping AFTER "composer install" was executed.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TranslationCommand extends Command
|
||||
{
|
||||
private $projectDirectory;
|
||||
private $environment;
|
||||
private $languageService;
|
||||
|
||||
public function __construct(string $projectDirectory, string $kernelEnvironment, LanguageService $languageService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->projectDirectory = $projectDirectory;
|
||||
$this->environment = $kernelEnvironment;
|
||||
$this->languageService = $languageService;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('kimai:translations')
|
||||
->setDescription('Translation adjustments')
|
||||
->addOption('resname', null, InputOption::VALUE_NONE, 'Fix the resname vs. id attribute')
|
||||
->addOption('duplicates', null, InputOption::VALUE_NONE, 'Find duplicate translation keys')
|
||||
->addOption('delete-resname', null, InputOption::VALUE_REQUIRED, 'Deletes the translation by resname')
|
||||
->addOption('extension', null, InputOption::VALUE_NONE, 'Find translation files with wrong extensions')
|
||||
->addOption('fill-empty', null, InputOption::VALUE_NONE, 'Pre-fills empty translations with the english version')
|
||||
// DEEPL TRANSLATION FEATURE - UNTESTED
|
||||
->addOption('translate-locale', null, InputOption::VALUE_REQUIRED, 'Translate into the given locale with Deepl')
|
||||
// @see https://www.deepl.com/de/pro#developer
|
||||
->addOption('translate-deepl', null, InputOption::VALUE_REQUIRED, 'Translate using the "DeepL API Free" auth-key')
|
||||
;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->environment !== 'prod';
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$bases = [
|
||||
'core' => $this->projectDirectory . '/translations/*.xlf',
|
||||
'core_xliff' => $this->projectDirectory . '/translations/*.xliff',
|
||||
'plugins' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xlf',
|
||||
'plugins_xliff' => $this->projectDirectory . Kernel::PLUGIN_DIRECTORY . '/*/Resources/translations/*.xliff',
|
||||
];
|
||||
|
||||
if ($input->getOption('delete-resname')) {
|
||||
$files = glob($bases['core']);
|
||||
foreach ($files as $file) {
|
||||
$this->removeKey($file, $input->getOption('delete-resname'));
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fix resname vs. id
|
||||
// ==========================================================================
|
||||
if ($input->getOption('resname')) {
|
||||
foreach ($bases as $directory) {
|
||||
$files = glob($directory);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$this->fixXlfFile($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Fill empty translations with english version
|
||||
// ==========================================================================
|
||||
if ($input->getOption('fill-empty')) {
|
||||
$translateFrom = ['de-CH' => 'de', 'de_CH' => 'de', 'pt_BR' => 'pt', 'pt-BR' => 'pt', 'pt' => 'pt_BR'];
|
||||
$translations = [];
|
||||
foreach ($bases as $directory) {
|
||||
$files = glob($directory);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$base = basename($file);
|
||||
$parts = explode('.', $base);
|
||||
$name = $parts[0];
|
||||
$fileLocale = $parts[1];
|
||||
$fromLocale = 'en';
|
||||
if (\array_key_exists($fileLocale, $translateFrom)) {
|
||||
$fromLocale = $translateFrom[$fileLocale];
|
||||
}
|
||||
|
||||
if (!\array_key_exists($fromLocale, $translations)) {
|
||||
$translations[$fromLocale] = [];
|
||||
}
|
||||
|
||||
if (!\array_key_exists($name, $translations[$fromLocale])) {
|
||||
$fromLocaleName = str_replace('.' . $fileLocale . '.', '.' . $fromLocale . '.', $file);
|
||||
if (!file_exists($fromLocaleName)) {
|
||||
$io->error('Could not find translation file: ' . $fromLocaleName);
|
||||
|
||||
return 1;
|
||||
}
|
||||
$translations[$fromLocale][$name] = $this->getTranslations($fromLocaleName);
|
||||
}
|
||||
|
||||
if (stripos($base, '.' . $fromLocale . '.xlf') !== false || stripos($base, '.' . $fromLocale . '.xliff') !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->fixEmptyTranslations($file, $translations[$fromLocale][$name]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Find wrong file extensions
|
||||
// ==========================================================================
|
||||
if ($input->getOption('extension')) {
|
||||
foreach ([$bases['core'], $bases['plugins']] as $directory) {
|
||||
$files = glob($directory);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$file = str_replace($this->projectDirectory, '', $file);
|
||||
$io->warning($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Find duplicate translation keys
|
||||
// ==========================================================================
|
||||
if ($input->getOption('duplicates')) {
|
||||
$duplicates = [];
|
||||
|
||||
foreach ($bases as $directory) {
|
||||
foreach (glob($directory) as $file) {
|
||||
$xml = simplexml_load_file($file);
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
$n = (string) $unit['resname'];
|
||||
if (!\array_key_exists($n, $duplicates)) {
|
||||
$duplicates[$n] = [];
|
||||
}
|
||||
$b = explode('.', basename($file))[0];
|
||||
if (!\in_array($b, $duplicates[$n])) {
|
||||
$duplicates[$n][] = $b;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($duplicates as $id => $files) {
|
||||
if (\count($files) > 1) {
|
||||
$io->text($id . ' => ' . implode(', ', $files));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// DEEPL
|
||||
// ==========================================================================
|
||||
$locale = $input->getOption('translate-locale');
|
||||
$deepl = $input->getOption('translate-deepl');
|
||||
|
||||
if ($locale !== null && $deepl === null) {
|
||||
$io->error('Missing "DeepL API Free" auth-key');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($locale === null && $deepl !== null) {
|
||||
$io->error('Missing translation locale');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($locale !== null && $deepl !== null) {
|
||||
// see https://github.com/octfx/DeepLy/blob/master/src/DeepLy.php
|
||||
$deeplySupportedLanguages = [
|
||||
'de' => 'DE',
|
||||
'en' => 'EN-US',
|
||||
'fr' => 'FR',
|
||||
'it' => 'IT',
|
||||
'ja' => 'JA',
|
||||
'es' => 'ES',
|
||||
'nl' => 'NL',
|
||||
'pl' => 'PL',
|
||||
'pt' => 'PT-PT', // ???
|
||||
'pt_BR' => 'PT-BR', // ???
|
||||
'ru' => 'RU',
|
||||
'zh_CN' => 'ZH',
|
||||
];
|
||||
|
||||
$locale = strtolower($locale);
|
||||
if (!$this->languageService->isKnownLanguage($locale)) {
|
||||
$io->error('Unknown locale given: ' . $locale);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($locale, $deeplySupportedLanguages)) {
|
||||
$io->error('Locale not supported by Deeply: ' . $locale);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$allKeys = 0;
|
||||
$enFiles = glob($bases['core'] . '/*.en.xlf');
|
||||
|
||||
$baseUrl = 'https://api-free.deepl.com/v2/translate';
|
||||
$client = HttpClient::create([]);
|
||||
|
||||
foreach ($enFiles as $file) {
|
||||
$enTrans = [];
|
||||
$domain = explode('.', basename($file))[0];
|
||||
|
||||
$xml = simplexml_load_file($file);
|
||||
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
$id = (string) $unit['id'];
|
||||
$enTrans[$id] = [
|
||||
'resname' => (string) $unit['resname'],
|
||||
'source' => (string) $unit->source,
|
||||
'target' => (string) $unit->target
|
||||
];
|
||||
$allKeys++;
|
||||
}
|
||||
|
||||
$localeFile = $bases['core'] . '/' . $domain . '.' . $locale . '.xlf';
|
||||
|
||||
$translated = [];
|
||||
|
||||
if (file_exists($localeFile)) {
|
||||
$xml2 = simplexml_load_file($localeFile);
|
||||
foreach ($xml2->file->body->{'trans-unit'} as $unit) {
|
||||
$id = (string) $unit['id'];
|
||||
$translated[$id] = [
|
||||
'resname' => (string) $unit['resname'],
|
||||
'source' => (string) $unit->source,
|
||||
'target' => (string) $unit->target
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$missingIds = array_diff(array_keys($enTrans), array_keys($translated));
|
||||
if (\count($missingIds) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$io->title('Translating ' . $domain);
|
||||
$progress = new ProgressBar($output, \count($missingIds));
|
||||
|
||||
foreach ($missingIds as $id) {
|
||||
$progress->advance();
|
||||
|
||||
$values = $enTrans[$id];
|
||||
$translated[$id] = $values;
|
||||
|
||||
$params = [
|
||||
'auth_key' => $deepl,
|
||||
//'split_sentences' => '1',
|
||||
//'preserve_formatting' => '0',
|
||||
'formality' => 'default',
|
||||
'text' => $values['target'],
|
||||
'source_lang' => 'en',
|
||||
'target_lang' => $deeplySupportedLanguages[$locale],
|
||||
];
|
||||
|
||||
$rawResponseData = null;
|
||||
try {
|
||||
$rawResponseData = $client->request('POST', $baseUrl, ['body' => $params]);
|
||||
} catch (\Exception $exception) {
|
||||
$io->error($exception->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$json = json_decode($rawResponseData->getContent(), true);
|
||||
$translation = $json['translations'][0]['text'];
|
||||
|
||||
$translated[$id]['target'] = $translation;
|
||||
}
|
||||
|
||||
$progress->finish();
|
||||
$io->writeln(PHP_EOL);
|
||||
|
||||
$this->writeXliffFile($bases['core'], $domain, $locale, $translated);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getTranslations(string $file): array
|
||||
{
|
||||
$translations = [];
|
||||
|
||||
$xml = simplexml_load_file($file);
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
if (!isset($unit['resname'])) {
|
||||
throw new \Exception('Missing "resname" attribute in file: ' . $file);
|
||||
}
|
||||
|
||||
$source = (string) $unit['resname'];
|
||||
$translations[$source] = (string) $unit->target;
|
||||
}
|
||||
|
||||
return $translations;
|
||||
}
|
||||
|
||||
private function writeXliffFile(string $base, string $domain, string $locale, array $translations = []): void
|
||||
{
|
||||
$from = $base . '/' . $domain . '.en.xlf';
|
||||
$to = $base . '/' . $domain . '.' . $locale . '.xlf';
|
||||
|
||||
copy($from, $to);
|
||||
|
||||
$xml = simplexml_load_file($to);
|
||||
|
||||
/** @var \SimpleXMLElement $fileNode */
|
||||
$fileNode = $xml->file;
|
||||
$fileNode->attributes()->{'target-language'} = $locale;
|
||||
$fileNode->attributes()->{'original'} = $domain . '.en.xlf';
|
||||
|
||||
unset($xml->file->body);
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0', 'UTF-8');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
$xpath = new \DOMXpath($xmlDocument);
|
||||
$xpath->registerNamespace('ns', $xmlDocument->documentElement->namespaceURI);
|
||||
|
||||
$xmlContent = '';
|
||||
foreach ($translations as $id => $values) {
|
||||
$xmlContent .= sprintf(
|
||||
'<trans-unit id="%s" resname="%s"><source>%s</source><target>%s</target></trans-unit>',
|
||||
$id,
|
||||
$values['resname'],
|
||||
$values['source'],
|
||||
$values['target']
|
||||
);
|
||||
}
|
||||
|
||||
$fragment = $xmlDocument->createDocumentFragment();
|
||||
$fragment->appendXml('<body>' . $xmlContent . '</body>');
|
||||
|
||||
/** @var \DOMElement $element */
|
||||
$element = $xpath->evaluate('/ns:xliff/ns:file')->item(0);
|
||||
$element->appendChild($fragment);
|
||||
|
||||
file_put_contents($to, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
private function fixXlfFile(string $file): void
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
if (isset($xml->file->header)) {
|
||||
unset($xml->file->header);
|
||||
}
|
||||
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
$source = $unit->source;
|
||||
if (!isset($unit['resname'])) {
|
||||
$unit['resname'] = $source;
|
||||
}
|
||||
$unit['id'] = $this->generateId($unit['resname']);
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
file_put_contents($file, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
private function fixEmptyTranslations(string $file, array $translations): void
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
$foundEmpty = false;
|
||||
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
if (!isset($unit['resname'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) $unit['resname'];
|
||||
|
||||
$translation = (string) $unit->target;
|
||||
if (\strlen($translation) > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!\array_key_exists($key, $translations)) {
|
||||
throw new \Exception(
|
||||
sprintf('Missing english translation for key: %s in file %s', $key, $file)
|
||||
);
|
||||
}
|
||||
$unit->target[0] = $translations[$key];
|
||||
$unit->target['state'] = 'needs-translation';
|
||||
$foundEmpty = true;
|
||||
}
|
||||
|
||||
if (!$foundEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
file_put_contents($file, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
private function removeKey(string $file, string $key): void
|
||||
{
|
||||
$xml = simplexml_load_file($file);
|
||||
|
||||
/** @var \SimpleXMLElement $unit */
|
||||
foreach ($xml->file->body->{'trans-unit'} as $unit) {
|
||||
if (!isset($unit['resname'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((string) $unit['resname'] === $key) {
|
||||
$dom = dom_import_simplexml($unit);
|
||||
$dom->parentNode->removeChild($dom);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$xmlDocument = new \DOMDocument('1.0');
|
||||
$xmlDocument->preserveWhiteSpace = false;
|
||||
$xmlDocument->formatOutput = true;
|
||||
$xmlDocument->loadXML($xml->asXML());
|
||||
|
||||
file_put_contents($file, $xmlDocument->saveXML());
|
||||
}
|
||||
|
||||
private function generateId(string $source): string
|
||||
{
|
||||
return strtr(substr(base64_encode(hash('sha256', $source, true)), 0, 7), '/+', '._');
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,10 @@ class VersionCommand extends Command
|
||||
->setName('kimai:version')
|
||||
->setDescription('Receive version information')
|
||||
->setHelp('This command allows you to fetch various version information about Kimai.')
|
||||
->addOption('name', null, InputOption::VALUE_NONE, 'Display the major release name')
|
||||
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
|
||||
->addOption('number', null, InputOption::VALUE_NONE, 'Display the version identifier only only')
|
||||
// @deprecated since 1.14.1
|
||||
->addOption('name', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the major release name')
|
||||
->addOption('candidate', null, InputOption::VALUE_NONE, 'DEPRECATED: Display the current version candidate (e.g. "stable" or "dev")')
|
||||
->addOption('semver', null, InputOption::VALUE_NONE, 'DEPRECATED: Semantical versioning (SEMVER) compatible version string')
|
||||
;
|
||||
@@ -46,6 +47,7 @@ class VersionCommand extends Command
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
if ($input->getOption('semver')) {
|
||||
@trigger_error('bin/console kimai:version --semver is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
@@ -58,18 +60,26 @@ class VersionCommand extends Command
|
||||
}
|
||||
|
||||
if ($input->getOption('name')) {
|
||||
@trigger_error('bin/console kimai:version --name is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::NAME);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($input->getOption('candidate')) {
|
||||
@trigger_error('bin/console kimai:version --candidate is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
$io->writeln(Constants::STATUS);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln(Constants::SOFTWARE . ' ' . Constants::VERSION . ' by Kevin Papst and contributors.');
|
||||
if ($input->getOption('number')) {
|
||||
$io->writeln((string) Constants::VERSION_ID);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('%s <info>%s</info> by Kevin Papst and contributors.', Constants::SOFTWARE, Constants::VERSION));
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
final class SamlConfiguration
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
final class SamlConfiguration implements SamlConfigurationInterface
|
||||
{
|
||||
private $configuration;
|
||||
|
||||
|
||||
25
src/Configuration/SamlConfigurationInterface.php
Normal file
25
src/Configuration/SamlConfigurationInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Configuration;
|
||||
|
||||
interface SamlConfigurationInterface
|
||||
{
|
||||
public function isActivated(): bool;
|
||||
|
||||
public function getTitle(): string;
|
||||
|
||||
public function getAttributeMapping(): array;
|
||||
|
||||
public function getRolesAttribute(): ?string;
|
||||
|
||||
public function getRolesMapping(): array;
|
||||
|
||||
public function getConnection(): array;
|
||||
}
|
||||
@@ -54,29 +54,8 @@ trait StringAccessibleConfigTrait
|
||||
return;
|
||||
}
|
||||
|
||||
// this foreach should be replaced by a better piece of code,
|
||||
// especially the pointers could be a problem in the future
|
||||
foreach ($this->getConfigurations($this->repository) as $configuration) {
|
||||
$temp = explode('.', $configuration->getName());
|
||||
$array = &$this->settings;
|
||||
if ($temp[0] === $this->getPrefix()) {
|
||||
$temp = \array_slice($temp, 1);
|
||||
}
|
||||
foreach ($temp as $key2) {
|
||||
if (!\array_key_exists($key2, $array)) {
|
||||
$array[$key2] = $configuration->getValue();
|
||||
continue;
|
||||
}
|
||||
if (\is_array($array[$key2])) {
|
||||
$array = &$array[$key2];
|
||||
} elseif (\is_bool($array[$key2])) {
|
||||
$array[$key2] = (bool) $configuration->getValue();
|
||||
} elseif (\is_int($array[$key2])) {
|
||||
$array[$key2] = (int) $configuration->getValue();
|
||||
} else {
|
||||
$array[$key2] = $configuration->getValue();
|
||||
}
|
||||
}
|
||||
$this->set($configuration->getName(), $configuration->getValue());
|
||||
}
|
||||
|
||||
$this->initialized = true;
|
||||
@@ -100,7 +79,7 @@ trait StringAccessibleConfigTrait
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @return mixed
|
||||
* @return string|int|bool|float|null|array
|
||||
*/
|
||||
public function find(string $key)
|
||||
{
|
||||
@@ -181,7 +160,7 @@ trait StringAccessibleConfigTrait
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetSet()');
|
||||
$this->set($offset, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,4 +171,43 @@ trait StringAccessibleConfigTrait
|
||||
{
|
||||
throw new \BadMethodCallException('SystemBundleConfiguration does not support offsetUnset()');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an array item to a given value using "dot" notation.
|
||||
*
|
||||
* If no key is given to the method, the entire array will be replaced.
|
||||
*
|
||||
* @see https://github.com/divineomega/array_undot
|
||||
* @param string $key
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function set(string $key, $value): array
|
||||
{
|
||||
$array = &$this->settings;
|
||||
$keys = explode('.', $key);
|
||||
while (\count($keys) > 1) {
|
||||
$key = array_shift($keys);
|
||||
if (!isset($array[$key]) || !\is_array($array[$key])) {
|
||||
$array[$key] = [];
|
||||
}
|
||||
|
||||
$array = &$array[$key];
|
||||
}
|
||||
|
||||
$k = array_shift($keys);
|
||||
|
||||
if (\array_key_exists($k, $array)) {
|
||||
if (\is_bool($array[$k])) {
|
||||
$value = (bool) $value;
|
||||
} elseif (\is_int($array[$k])) {
|
||||
$value = (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
$array[$k] = $value;
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,11 +232,6 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (string) $this->find('timesheet.default_begin');
|
||||
}
|
||||
|
||||
public function getTimesheetDefaultBillable(): bool
|
||||
{
|
||||
return (bool) $this->find('defaults.timesheet.billable');
|
||||
}
|
||||
|
||||
public function isTimesheetAllowFutureTimes(): bool
|
||||
{
|
||||
return (bool) $this->find('timesheet.rules.allow_future_times');
|
||||
@@ -352,6 +347,11 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return $this->getIncrement('timesheet.time_increment', $this->getTimesheetDefaultRoundingEnd(), 0);
|
||||
}
|
||||
|
||||
public function getQuickEntriesRecentAmount(): int
|
||||
{
|
||||
return $this->getIncrement('quick_entry.recent_activities', 5, 0) ?? 5;
|
||||
}
|
||||
|
||||
// ========== Company configurations ==========
|
||||
|
||||
public function getFinancialYearStart(): ?string
|
||||
|
||||
@@ -21,10 +21,7 @@ final class ThemeConfiguration implements \ArrayAccess
|
||||
$this->systemConfiguration = $systemConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function offsetExists($offset)
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return $this->systemConfiguration->has('theme.' . $offset);
|
||||
}
|
||||
@@ -42,7 +39,7 @@ final class ThemeConfiguration implements \ArrayAccess
|
||||
* @param mixed $value
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetSet($offset, $value)
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
throw new \BadMethodCallException('ThemeConfiguration does not support offsetSet()');
|
||||
}
|
||||
@@ -51,7 +48,7 @@ final class ThemeConfiguration implements \ArrayAccess
|
||||
* @param mixed $offset
|
||||
* @throws \BadMethodCallException
|
||||
*/
|
||||
public function offsetUnset($offset)
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
throw new \BadMethodCallException('ThemeConfiguration does not support offsetUnset()');
|
||||
}
|
||||
|
||||
@@ -22,4 +22,14 @@ class ConsoleApplication extends Application
|
||||
{
|
||||
return Constants::VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwritten to prevent unwanted SF core messages to show up here.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getLongVersion()
|
||||
{
|
||||
return sprintf('%s <info>%s</info> (env: <comment>%s</>, debug: <comment>%s</>)', $this->getName(), $this->getVersion(), $this->getKernel()->getEnvironment(), $this->getKernel()->isDebug() ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,15 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '1.16.0';
|
||||
public const VERSION = '1.19.7';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 11600;
|
||||
public const VERSION_ID = 11907;
|
||||
/**
|
||||
* The current release status, either "stable" or "dev"
|
||||
*/
|
||||
public const STATUS = 'prod';
|
||||
public const STATUS = 'stable';
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\LocaleFormats;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
@@ -39,6 +40,13 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
public function createFormForGetRequest(string $type = FormType::class, $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->container
|
||||
->get('form.factory')
|
||||
->createNamed('', $type, $data, $options);
|
||||
}
|
||||
|
||||
private function getLogger(): LoggerInterface
|
||||
{
|
||||
return $this->container->get('logger');
|
||||
@@ -207,6 +215,19 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
throw new \InvalidArgumentException('handleSearchForm() requires an instanceof BaseQuery as form data');
|
||||
}
|
||||
|
||||
$actions = ['resetSearchFilter', 'removeDefaultQuery', 'setDefaultQuery'];
|
||||
foreach ($actions as $action) {
|
||||
if ($request->query->has($action)) {
|
||||
if (!$this->isCsrfTokenValid('search', $request->query->get('_token'))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$request->query->remove('_token');
|
||||
|
||||
if ($request->query->has('resetSearchFilter')) {
|
||||
$data->resetFilter();
|
||||
$this->removeLastSearch($data);
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Entity\ActivityRate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Event\ActivityDetailControllerEvent;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
@@ -140,6 +141,11 @@ final class ActivityController extends AbstractController
|
||||
$teams = $activity->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new ActivityDetailControllerEvent($activity);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
return $this->render('activity/details.html.twig', [
|
||||
'activity' => $activity,
|
||||
'stats' => $stats,
|
||||
@@ -147,6 +153,7 @@ final class ActivityController extends AbstractController
|
||||
'team' => $defaultTeam,
|
||||
'teams' => $teams,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Entity\CustomerComment;
|
||||
use App\Entity\CustomerRate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Team;
|
||||
use App\Event\CustomerDetailControllerEvent;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
@@ -172,7 +173,7 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('customer.delete_comment');
|
||||
|
||||
try {
|
||||
$this->repository->deleteComment($comment);
|
||||
@@ -219,7 +220,7 @@ final class CustomerController extends AbstractController
|
||||
return $this->redirectToRoute('customer_details', ['id' => $customerId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('customer.pin_comment');
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
try {
|
||||
@@ -300,7 +301,6 @@ final class CustomerController extends AbstractController
|
||||
$attachments = [];
|
||||
$comments = null;
|
||||
$teams = null;
|
||||
$projects = null;
|
||||
$rates = [];
|
||||
$now = $this->getDateTimeFactory()->createDateTime();
|
||||
|
||||
@@ -331,6 +331,11 @@ final class CustomerController extends AbstractController
|
||||
$teams = $customer->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new CustomerDetailControllerEvent($customer);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
return $this->render('customer/details.html.twig', [
|
||||
'customer' => $customer,
|
||||
'comments' => $comments,
|
||||
@@ -342,6 +347,7 @@ final class CustomerController extends AbstractController
|
||||
'customer_now' => new \DateTime('now', $timezone),
|
||||
'rates' => $rates,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class DoctorController extends AbstractController
|
||||
return $this->redirectToRoute('doctor');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('doctor.flush_log');
|
||||
|
||||
$logfile = $this->getLogFilename();
|
||||
|
||||
|
||||
@@ -13,11 +13,16 @@ use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\InvoiceCreatedMultipleEvent;
|
||||
use App\Event\InvoiceDocumentsEvent;
|
||||
use App\Event\InvoiceMetaDefinitionEvent;
|
||||
use App\Event\InvoiceMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\AnnotatedObjectExporter;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Form\InvoiceDocumentUploadForm;
|
||||
use App\Form\InvoicePaymentDateForm;
|
||||
use App\Form\InvoiceEditForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceArchiveForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
@@ -48,21 +53,9 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
*/
|
||||
final class InvoiceController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @var ServiceInvoice
|
||||
*/
|
||||
private $service;
|
||||
/**
|
||||
* @var InvoiceTemplateRepository
|
||||
*/
|
||||
private $templateRepository;
|
||||
/**
|
||||
* @var InvoiceRepository
|
||||
*/
|
||||
private $invoiceRepository;
|
||||
/**
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, EventDispatcherInterface $dispatcher)
|
||||
@@ -77,7 +70,7 @@ final class InvoiceController extends AbstractController
|
||||
* @Route(path="/", name="invoice", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function indexAction(Request $request, SystemConfiguration $configuration): Response
|
||||
public function indexAction(Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
@@ -87,6 +80,13 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
|
||||
$token = null;
|
||||
if ($request->query->has('token')) {
|
||||
$token = $request->query->get('token');
|
||||
$request->query->remove('token');
|
||||
}
|
||||
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
@@ -98,6 +98,14 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($form->isValid() && $this->isGranted('create_invoice')) {
|
||||
if ($request->query->has('createInvoice')) {
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('invoice.create');
|
||||
|
||||
try {
|
||||
return $this->renderInvoice($query, $request);
|
||||
} catch (Exception $ex) {
|
||||
@@ -130,118 +138,109 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/preview/{customer}/{template}", name="invoice_preview", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
* @Route(path="/preview/{customer}/{token}", name="invoice_preview", methods={"GET"})
|
||||
* @Security("is_granted('access', customer)")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function previewAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
|
||||
public function previewAction(Customer $customer, string $token, Request $request, SystemConfiguration $configuration): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.preview', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
// do not refresh token, preview is opening in new tabs and the listing page does not reload
|
||||
// so the new token would not be loaded
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$form->submit($request->query->all(), false);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if ($form->isValid() && $this->isGranted('create_invoice')) {
|
||||
if ($form->isValid()) {
|
||||
try {
|
||||
$query->setTemplate($template);
|
||||
$query->setCustomers([$customer]);
|
||||
$model = $this->service->createModel($query);
|
||||
|
||||
return $this->service->renderInvoiceWithModel($model, $this->dispatcher);
|
||||
} catch (Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError('action.update.error', ['%reason%' => 'Failed generating invoice preview: ' . $ex->getMessage()]);
|
||||
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
|
||||
}
|
||||
} else {
|
||||
$this->flashFormError($form);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/save-invoice/{customer}/{template}", name="invoice_create", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
* @Route(path="/save-invoice/{customer}/{template}/{token}", name="invoice_create", methods={"GET"})
|
||||
* @Security("is_granted('access', customer)")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
|
||||
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, string $token, Request $request, SystemConfiguration $configuration, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('invoice.create', $token)) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$form->submit($request->query->all(), false);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
if ($form->isValid() && $this->isGranted('create_invoice')) {
|
||||
if ($form->isValid()) {
|
||||
$query->setTemplate($template);
|
||||
$query->setCustomers([$customer]);
|
||||
|
||||
return $this->renderInvoice($query, $request);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
private function getDefaultQuery(): InvoiceQuery
|
||||
{
|
||||
$factory = $this->getDateTimeFactory();
|
||||
$begin = $factory->getStartOfMonth();
|
||||
$end = $factory->getEndOfMonth();
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setBegin($begin);
|
||||
$query->setEnd($end);
|
||||
// limit access to data from teams
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
if (!$this->isGranted('view_other_timesheet')) {
|
||||
// limit access to own data
|
||||
$query->setUser($this->getUser());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function renderInvoice(InvoiceQuery $query, Request $request)
|
||||
{
|
||||
// use the current request locale as fallback, if no translation was configured
|
||||
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
|
||||
$query->getTemplate()->setLanguage($request->getLocale());
|
||||
}
|
||||
|
||||
try {
|
||||
$invoices = $this->service->createInvoices($query, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if (\count($invoices) === 1) {
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
$this->flashFormError($form);
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET", "POST"})
|
||||
* @Route(path="/change-status/{id}/{status}/{token}", name="admin_invoice_status", methods={"GET", "POST"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function changeStatusAction(Invoice $invoice, string $status, Request $request): Response
|
||||
public function changeStatusAction(Invoice $invoice, string $status, string $token, Request $request, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
if ($status === Invoice::STATUS_PAID) {
|
||||
$form = $this->createPaymentDateForm($invoice, $status);
|
||||
if (null === $invoice->getPaymentDate()) {
|
||||
$invoice->setPaymentDate($this->getDateTimeFactory()->createDateTime());
|
||||
$invoice->setIsPaid();
|
||||
}
|
||||
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (!$form->isSubmitted() || !$form->isValid()) {
|
||||
return $this->render('invoice/payment_date_edit.html.twig', [
|
||||
'invoice' => $invoice,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
return $this->render('invoice/invoice_edit.html.twig', [
|
||||
'invoice' => $invoice,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -254,21 +253,50 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/edit/{id}", name="admin_invoice_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function editAction(Invoice $invoice, Request $request): Response
|
||||
{
|
||||
$form = $this->createInvoiceEditForm($invoice);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
$this->flashSuccess('action.update.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
return $this->render('invoice/invoice_edit.html.twig', [
|
||||
'invoice' => $invoice,
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('delete_invoice')")
|
||||
*/
|
||||
public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete', $token))) {
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.status', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('invoice.status');
|
||||
|
||||
try {
|
||||
$this->service->deleteInvoice($invoice);
|
||||
$this->service->deleteInvoice($invoice, $this->dispatcher);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
@@ -279,6 +307,8 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
/**
|
||||
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
|
||||
* @Security("is_granted('access', invoice.getCustomer())")
|
||||
* @Security("is_granted('create_invoice')")
|
||||
*/
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
@@ -295,6 +325,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
/**
|
||||
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
@@ -320,11 +351,13 @@ final class InvoiceController extends AbstractController
|
||||
'query' => $query,
|
||||
'toolbarForm' => $form->createView(),
|
||||
'download' => $invoice,
|
||||
'metaColumns' => $this->findMetaColumns($query),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="invoice_export", methods={"GET"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function exportAction(Request $request, AnnotatedObjectExporter $exporter)
|
||||
{
|
||||
@@ -382,17 +415,45 @@ final class InvoiceController extends AbstractController
|
||||
$invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir;
|
||||
}
|
||||
|
||||
$used = [];
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
$used[$template->getRenderer()] = $template;
|
||||
}
|
||||
|
||||
$event = new InvoiceDocumentsEvent($this->service->getDocuments(true));
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$documents = [];
|
||||
foreach ($event->getInvoiceDocuments() as $document) {
|
||||
$isUsed = \array_key_exists($document->getId(), $used);
|
||||
$template = null;
|
||||
if ($isUsed) {
|
||||
$template = $used[$document->getId()];
|
||||
}
|
||||
$documents[] = [
|
||||
'document' => $document,
|
||||
'template' => $template,
|
||||
'used' => $isUsed,
|
||||
];
|
||||
}
|
||||
|
||||
$canUpload = true;
|
||||
$uploadError = null;
|
||||
|
||||
if (\count($documents) >= $event->getMaximumAllowedDocuments()) {
|
||||
$uploadError = 'invoice_document.max_reached';
|
||||
$canUpload = false;
|
||||
}
|
||||
|
||||
if (!file_exists($invoiceDir)) {
|
||||
@mkdir($invoiceDir, 0777);
|
||||
}
|
||||
|
||||
if (!is_dir($invoiceDir)) {
|
||||
$this->flashError(sprintf('Invoice directory "%s" is not existing and could not be created.', $dir));
|
||||
$uploadError = 'error.directory_missing';
|
||||
$canUpload = false;
|
||||
} elseif (!is_writable($invoiceDir)) {
|
||||
$this->flashError(sprintf('Invoice directory "%s" cannot be written.', $dir));
|
||||
$uploadError = 'error.directory_protected';
|
||||
$canUpload = false;
|
||||
}
|
||||
|
||||
@@ -413,7 +474,10 @@ final class InvoiceController extends AbstractController
|
||||
'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
|
||||
$originalFilename
|
||||
);
|
||||
$newFilename = $safeFilename . '.' . $uploadedFile->guessExtension();
|
||||
|
||||
$extension = $uploadedFile->guessExtension();
|
||||
|
||||
$newFilename = substr($safeFilename, 0, 20) . '.' . $extension;
|
||||
|
||||
try {
|
||||
$uploadedFile->move($invoiceDir, $newFilename);
|
||||
@@ -427,12 +491,56 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
return $this->render('invoice/document_upload.html.twig', [
|
||||
'error_replacer' => ['%max%' => $event->getMaximumAllowedDocuments(), '%dir%' => $dir],
|
||||
'upload_error' => $uploadError,
|
||||
'can_upload' => $canUpload,
|
||||
'form' => $form->createView(),
|
||||
'documents' => $this->service->getDocuments(true),
|
||||
'documents' => $documents,
|
||||
'baseDirectory' => $projectDirectory . DIRECTORY_SEPARATOR,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/document/{id}/delete/{token}", name="invoice_document_delete", methods={"GET", "POST"})
|
||||
* @Security("is_granted('manage_invoice_template')")
|
||||
*/
|
||||
public function deleteDocument(string $id, string $token, CsrfTokenManagerInterface $csrfTokenManager, InvoiceDocumentRepository $documentRepository): Response
|
||||
{
|
||||
$document = $documentRepository->findByName($id);
|
||||
if ($document === null) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_document', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_document_upload');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('invoice.delete_document');
|
||||
|
||||
foreach ($documentRepository->findBuiltIn() as $doc) {
|
||||
if ($doc->getId() === $id) {
|
||||
throw new \Exception('Document is built-in and cannot be deleted');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->templateRepository->findAll() as $template) {
|
||||
if ($template->getRenderer() === $id) {
|
||||
throw new \Exception('Document is used and cannot be deleted');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$documentRepository->remove($document);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashDeleteException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_document_upload');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
|
||||
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
|
||||
@@ -462,7 +570,7 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('invoice.delete_template');
|
||||
|
||||
try {
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
@@ -474,9 +582,65 @@ final class InvoiceController extends AbstractController
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
}
|
||||
|
||||
private function getDefaultQuery(): InvoiceQuery
|
||||
{
|
||||
$factory = $this->getDateTimeFactory();
|
||||
$begin = $factory->getStartOfMonth();
|
||||
$end = $factory->getEndOfMonth();
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setBegin($begin);
|
||||
$query->setEnd($end);
|
||||
// limit access to data from teams
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
if (!$this->isGranted('view_other_timesheet')) {
|
||||
// limit access to own data
|
||||
$query->setUser($this->getUser());
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function renderInvoice(InvoiceQuery $query, Request $request)
|
||||
{
|
||||
// use the current request locale as fallback, if no translation was configured
|
||||
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
|
||||
$query->getTemplate()->setLanguage($request->getLocale());
|
||||
}
|
||||
|
||||
try {
|
||||
$invoices = $this->service->createInvoices($query, $this->dispatcher);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if (\count($invoices) === 1) {
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
|
||||
} elseif (\count($invoices) > 1) {
|
||||
$this->dispatcher->dispatch(new InvoiceCreatedMultipleEvent($invoices));
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
} catch (Exception $ex) {
|
||||
$this->flashUpdateException($ex);
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('invoice');
|
||||
}
|
||||
|
||||
private function flashFormError(FormInterface $form): void
|
||||
{
|
||||
$err = '';
|
||||
foreach ($form->getErrors(true, true) as $error) {
|
||||
$err .= PHP_EOL . '[' . $error->getOrigin()->getName() . '] ' . $error->getMessage();
|
||||
}
|
||||
|
||||
$this->flashError('action.update.error', ['%reason%' => $err]);
|
||||
}
|
||||
|
||||
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
$editForm = $this->createEditForm($template);
|
||||
$editForm = $this->createTemplateEditForm($template);
|
||||
|
||||
$editForm->handleRequest($request);
|
||||
|
||||
@@ -524,7 +688,7 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEditForm(InvoiceTemplate $template): FormInterface
|
||||
private function createTemplateEditForm(InvoiceTemplate $template): FormInterface
|
||||
{
|
||||
if ($template->getId() === null) {
|
||||
$url = $this->generateUrl('admin_invoice_template_create');
|
||||
@@ -538,16 +702,25 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPaymentDateForm(Invoice $invoice, string $status): FormInterface
|
||||
/**
|
||||
* @param InvoiceArchiveQuery $query
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(InvoiceArchiveQuery $query): array
|
||||
{
|
||||
if (null === $invoice->getPaymentDate()) {
|
||||
$invoice->setPaymentDate($this->getDateTimeFactory()->createDateTime());
|
||||
}
|
||||
$event = new InvoiceMetaDisplayEvent($query, InvoiceMetaDisplayEvent::INVOICE);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$url = $this->generateUrl('admin_invoice_status', ['id' => $invoice->getId(), 'status' => $status]);
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
return $this->createForm(InvoicePaymentDateForm::class, $invoice, [
|
||||
'action' => $url,
|
||||
private function createInvoiceEditForm(Invoice $invoice): FormInterface
|
||||
{
|
||||
$event = new InvoiceMetaDefinitionEvent($invoice);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $this->createForm(InvoiceEditForm::class, $invoice, [
|
||||
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]),
|
||||
'method' => 'POST',
|
||||
'timezone' => $this->getDateTimeFactory()->getTimezone()->getName(),
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\TeamMember;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Event\PrepareUserEvent;
|
||||
@@ -19,7 +18,9 @@ use App\Form\UserPasswordType;
|
||||
use App\Form\UserPreferencesForm;
|
||||
use App\Form\UserRolesType;
|
||||
use App\Form\UserTeamsType;
|
||||
use App\Repository\TeamRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use App\User\UserService;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -78,15 +79,13 @@ final class ProfileController extends AbstractController
|
||||
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', profile)")
|
||||
*/
|
||||
public function editAction(User $profile, Request $request): Response
|
||||
public function editAction(User $profile, Request $request, UserRepository $userRepository): Response
|
||||
{
|
||||
$form = $this->createEditForm($profile);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
$userRepository->saveUser($profile);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -152,7 +151,7 @@ final class ProfileController extends AbstractController
|
||||
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
|
||||
* @Security("is_granted('roles', profile)")
|
||||
*/
|
||||
public function rolesAction(User $profile, Request $request): Response
|
||||
public function rolesAction(User $profile, Request $request, UserRepository $userRepository): Response
|
||||
{
|
||||
$isSuperAdmin = $profile->isSuperAdmin();
|
||||
|
||||
@@ -166,9 +165,7 @@ final class ProfileController extends AbstractController
|
||||
$profile->setSuperAdmin(true);
|
||||
}
|
||||
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
$userRepository->saveUser($profile);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
@@ -186,7 +183,7 @@ final class ProfileController extends AbstractController
|
||||
* @Route(path="/{username}/teams", name="user_profile_teams", methods={"GET", "POST"})
|
||||
* @Security("is_granted('teams', profile)")
|
||||
*/
|
||||
public function teamsAction(User $profile, Request $request, UserService $service): Response
|
||||
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response
|
||||
{
|
||||
$originalMembers = new ArrayCollection();
|
||||
foreach ($profile->getMemberships() as $member) {
|
||||
@@ -197,18 +194,15 @@ final class ProfileController extends AbstractController
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$entityManager = $this->getDoctrine()->getManager();
|
||||
|
||||
/** @var TeamMember $member */
|
||||
foreach ($originalMembers as $member) {
|
||||
if (!$profile->hasMembership($member)) {
|
||||
$member->getTeam()->removeMember($member);
|
||||
$entityManager->remove($profile);
|
||||
$member->setTeam(null);
|
||||
$member->setUser(null);
|
||||
$teamRepository->removeTeamMember($member);
|
||||
}
|
||||
}
|
||||
|
||||
$entityManager->persist($profile);
|
||||
$entityManager->flush();
|
||||
$userRepository->saveUser($profile);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use App\Entity\ProjectComment;
|
||||
use App\Entity\ProjectRate;
|
||||
use App\Entity\Rate;
|
||||
use App\Entity\Team;
|
||||
use App\Event\ProjectDetailControllerEvent;
|
||||
use App\Event\ProjectMetaDefinitionEvent;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Export\Spreadsheet\EntityWithMetaFieldsExporter;
|
||||
@@ -194,7 +195,7 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('project.delete_comment');
|
||||
|
||||
try {
|
||||
$this->repository->deleteComment($comment);
|
||||
@@ -241,7 +242,7 @@ final class ProjectController extends AbstractController
|
||||
return $this->redirectToRoute('project_details', ['id' => $projectId]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken($token);
|
||||
$csrfTokenManager->refreshToken('project.pin_comment');
|
||||
|
||||
$comment->setPinned(!$comment->isPinned());
|
||||
try {
|
||||
@@ -348,6 +349,11 @@ final class ProjectController extends AbstractController
|
||||
$teams = $project->getTeams();
|
||||
}
|
||||
|
||||
// additional boxes by plugins
|
||||
$event = new ProjectDetailControllerEvent($project);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$boxes = $event->getController();
|
||||
|
||||
return $this->render('project/details.html.twig', [
|
||||
'project' => $project,
|
||||
'comments' => $comments,
|
||||
@@ -358,6 +364,7 @@ final class ProjectController extends AbstractController
|
||||
'teams' => $teams,
|
||||
'rates' => $rates,
|
||||
'now' => $now,
|
||||
'boxes' => $boxes
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -421,13 +428,23 @@ final class ProjectController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/duplicate", name="admin_project_duplicate", methods={"GET", "POST"})
|
||||
* @Route(path="/{id}/duplicate/{token}", name="admin_project_duplicate", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', project)")
|
||||
*/
|
||||
public function duplicateAction(Project $project, Request $request, ProjectDuplicationService $projectDuplicationService)
|
||||
public function duplicateAction(Project $project, string $token, ProjectDuplicationService $projectDuplicationService, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.duplicate', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('project.duplicate');
|
||||
|
||||
$newProject = $projectDuplicationService->duplicate($project, $project->getName() . ' [COPY]');
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('project_details', ['id' => $newProject->getId()]);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
* Controller used to enter times in weekly form.
|
||||
*
|
||||
* @Route(path="/quick_entry")
|
||||
* @Security("is_granted('weekly_own_timesheet') and is_granted('edit_own_timesheet')")
|
||||
* @Security("is_granted('quick-entry')")
|
||||
*/
|
||||
class QuickEntryController extends AbstractController
|
||||
{
|
||||
@@ -45,14 +45,6 @@ class QuickEntryController extends AbstractController
|
||||
*/
|
||||
public function quickEntry(Request $request, ?string $begin = null)
|
||||
{
|
||||
$mode = $this->timesheetService->getActiveTrackingMode();
|
||||
|
||||
if (!$mode->canEditDuration() && !$mode->canEditEnd()) {
|
||||
$this->flashError('Not allowed');
|
||||
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
$factory = $this->getDateTimeFactory();
|
||||
if ($begin === null) {
|
||||
$begin = $factory->createDateTime();
|
||||
@@ -106,7 +98,8 @@ class QuickEntryController extends AbstractController
|
||||
ksort($rows);
|
||||
|
||||
// attach recent activities
|
||||
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, 5);
|
||||
$amount = $this->configuration->getQuickEntriesRecentAmount();
|
||||
$timesheets = $this->repository->getRecentActivities($this->getUser(), null, $amount);
|
||||
foreach ($timesheets as $timesheet) {
|
||||
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
|
||||
if (\array_key_exists($id, $rows)) {
|
||||
|
||||
125
src/Controller/Reporting/AbstractUserReportController.php
Normal file
125
src/Controller/Reporting/AbstractUserReportController.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Model\DateStatisticInterface;
|
||||
use App\Model\Statistic\StatisticDate;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use DateTime;
|
||||
|
||||
abstract class AbstractUserReportController extends AbstractController
|
||||
{
|
||||
protected $statisticService;
|
||||
private $projectRepository;
|
||||
private $activityRepository;
|
||||
|
||||
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
|
||||
{
|
||||
$this->statisticService = $statisticService;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->activityRepository = $activityRepository;
|
||||
}
|
||||
|
||||
protected function canSelectUser(): bool
|
||||
{
|
||||
// also found in App\EventSubscriber\Actions\UserSubscriber
|
||||
if (!$this->isGranted('view_other_timesheet') || !$this->isGranted('view_other_reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getStatisticDataRaw(DateTime $begin, DateTime $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTime $begin, DateTime $end, User $user): DateStatisticInterface
|
||||
{
|
||||
return new DailyStatistic($begin, $end, $user);
|
||||
}
|
||||
|
||||
protected function prepareReport(DateTime $begin, DateTime $end, User $user): array
|
||||
{
|
||||
$data = $this->getStatisticDataRaw($begin, $end, $user);
|
||||
|
||||
$data = array_pop($data);
|
||||
$projectIds = [];
|
||||
$activityIds = [];
|
||||
|
||||
foreach ($data as $projectId => $projectValues) {
|
||||
$projectIds[$projectId] = $projectId;
|
||||
$dailyProjectStatistic = $this->createStatisticModel($begin, $end, $user);
|
||||
foreach ($projectValues['activities'] as $activityId => $activityValues) {
|
||||
$activityIds[$activityId] = $activityId;
|
||||
if (!isset($data[$projectId]['duration'])) {
|
||||
$data[$projectId]['duration'] = 0;
|
||||
}
|
||||
if (!isset($data[$projectId]['rate'])) {
|
||||
$data[$projectId]['rate'] = 0.0;
|
||||
}
|
||||
if (!isset($data[$projectId]['internalRate'])) {
|
||||
$data[$projectId]['internalRate'] = 0.0;
|
||||
}
|
||||
if (!isset($data[$projectId]['activities'][$activityId]['duration'])) {
|
||||
$data[$projectId]['activities'][$activityId]['duration'] = 0;
|
||||
}
|
||||
if (!isset($data[$projectId]['activities'][$activityId]['rate'])) {
|
||||
$data[$projectId]['activities'][$activityId]['rate'] = 0.0;
|
||||
}
|
||||
if (!isset($data[$projectId]['activities'][$activityId]['internalRate'])) {
|
||||
$data[$projectId]['activities'][$activityId]['internalRate'] = 0.0;
|
||||
}
|
||||
/** @var StatisticDate $date */
|
||||
foreach ($activityValues['data']->getData() as $date) {
|
||||
$statisticDate = $dailyProjectStatistic->getByDateTime($date->getDate());
|
||||
if ($statisticDate === null) {
|
||||
// this should not happen, but sometimes it does ...
|
||||
continue;
|
||||
}
|
||||
$statisticDate->setTotalDuration($statisticDate->getTotalDuration() + $date->getTotalDuration());
|
||||
$statisticDate->setTotalRate($statisticDate->getTotalRate() + $date->getTotalRate());
|
||||
$statisticDate->setTotalInternalRate($statisticDate->getTotalInternalRate() + $date->getTotalInternalRate());
|
||||
$data[$projectId]['duration'] = $data[$projectId]['duration'] + $date->getTotalDuration();
|
||||
$data[$projectId]['rate'] = $data[$projectId]['rate'] + $date->getTotalRate();
|
||||
$data[$projectId]['internalRate'] = $data[$projectId]['internalRate'] + $date->getTotalInternalRate();
|
||||
$data[$projectId]['activities'][$activityId]['duration'] = $data[$projectId]['activities'][$activityId]['duration'] + $date->getTotalDuration();
|
||||
$data[$projectId]['activities'][$activityId]['rate'] = $data[$projectId]['activities'][$activityId]['rate'] + $date->getTotalRate();
|
||||
$data[$projectId]['activities'][$activityId]['internalRate'] = $data[$projectId]['activities'][$activityId]['internalRate'] + $date->getTotalInternalRate();
|
||||
}
|
||||
}
|
||||
$data[$projectId]['data'] = $dailyProjectStatistic;
|
||||
}
|
||||
|
||||
$activities = $this->activityRepository->findByIds($activityIds);
|
||||
foreach ($activities as $activity) {
|
||||
$activityIds[$activity->getId()] = $activity;
|
||||
}
|
||||
|
||||
foreach ($data as $projectId => $projectValues) {
|
||||
foreach ($projectValues['activities'] as $activityId => $activityValues) {
|
||||
$data[$projectId]['activities'][$activityId]['activity'] = $activityIds[$activityId];
|
||||
}
|
||||
}
|
||||
|
||||
$projects = $this->projectRepository->findByIds($projectIds);
|
||||
foreach ($projects as $project) {
|
||||
$data[$project->getId()]['project'] = $project;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
111
src/Controller/Reporting/CustomerMonthlyProjectsController.php
Normal file
111
src/Controller/Reporting/CustomerMonthlyProjectsController.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjects;
|
||||
use App\Reporting\CustomerMonthlyProjects\CustomerMonthlyProjectsForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/customer/monthly_projects")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
final class CustomerMonthlyProjectsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/view", name="report_customer_monthly_projects", methods={"GET","POST"})
|
||||
*/
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'reporting/customer/monthly_projects.html.twig',
|
||||
$this->getData($request, $statisticService, $userRepository)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/export", name="report_customer_monthly_projects_export", methods={"GET","POST"})
|
||||
*/
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->render('reporting/customer/monthly_projects_export.html.twig', $data)->getContent();
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-monthly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
|
||||
$values = new CustomerMonthlyProjects();
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createFormForGetRequest(CustomerMonthlyProjectsForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start = $dateTimeFactory->getStartOfMonth($start);
|
||||
$end = $dateTimeFactory->getEndOfMonth($start);
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 month');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 month');
|
||||
|
||||
$stats = $statisticService->getGroupedByCustomerProjectActivityUser($start, $end, $allUsers);
|
||||
|
||||
return [
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_customer_monthly_projects',
|
||||
'export_route' => 'report_customer_monthly_projects_export',
|
||||
'form' => $form->createView(),
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'decimal' => $values->isDecimal(),
|
||||
'stats' => $stats,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,234 +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\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Model\Statistic\StatisticDate;
|
||||
use App\Reporting\MonthByUser;
|
||||
use App\Reporting\MonthByUserForm;
|
||||
use App\Reporting\WeekByUser;
|
||||
use App\Reporting\WeekByUserForm;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use DateTime;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
final class ReportByUserController extends AbstractController
|
||||
{
|
||||
private $statisticService;
|
||||
private $projectRepository;
|
||||
private $activityRepository;
|
||||
|
||||
public function __construct(TimesheetStatisticService $statisticService, ProjectRepository $projectRepository, ActivityRepository $activityRepository)
|
||||
{
|
||||
$this->statisticService = $statisticService;
|
||||
$this->projectRepository = $projectRepository;
|
||||
$this->activityRepository = $activityRepository;
|
||||
}
|
||||
|
||||
private function canSelectUser(): bool
|
||||
{
|
||||
// also found in App\EventSubscriber\Actions\UserSubscriber
|
||||
if (!$this->isGranted('view_other_timesheet') || !$this->isGranted('view_other_reporting')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/month_by_user", name="report_user_month", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function monthByUser(Request $request): Response
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new MonthByUser();
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($values->getUser() === null) {
|
||||
$values->setUser($currentUser);
|
||||
}
|
||||
|
||||
if ($currentUser !== $values->getUser() && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previousMonth = clone $start;
|
||||
$previousMonth->modify('-1 month');
|
||||
|
||||
$nextMonth = clone $start;
|
||||
$nextMonth->modify('+1 month');
|
||||
|
||||
$data = $this->prepareReport($start, $end, $selectedUser);
|
||||
|
||||
return $this->render('reporting/report_by_user.html.twig', [
|
||||
'report_title' => 'report_user_month',
|
||||
'box_id' => 'user-month-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'rows' => $data,
|
||||
'days' => new DailyStatistic($start, $end, $selectedUser),
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $nextMonth,
|
||||
'previous' => $previousMonth,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/week_by_user", name="report_user_week", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function weekByUser(Request $request): Response
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new WeekByUser();
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeekByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($values->getUser() === null) {
|
||||
$values->setUser($currentUser);
|
||||
}
|
||||
|
||||
if ($currentUser !== $values->getUser() && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
|
||||
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 week');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 week');
|
||||
|
||||
$data = $this->prepareReport($start, $end, $selectedUser);
|
||||
|
||||
return $this->render('reporting/report_by_user.html.twig', [
|
||||
'report_title' => 'report_user_week',
|
||||
'box_id' => 'user-week-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'days' => new DailyStatistic($start, $end, $selectedUser),
|
||||
'rows' => $data,
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
]);
|
||||
}
|
||||
|
||||
private function prepareReport(DateTime $begin, DateTime $end, User $user): array
|
||||
{
|
||||
$data = $this->statisticService->getDailyStatisticsGrouped($begin, $end, [$user]);
|
||||
|
||||
$data = array_pop($data);
|
||||
$projectIds = [];
|
||||
$activityIds = [];
|
||||
|
||||
foreach ($data as $projectId => $projectValues) {
|
||||
$projectIds[$projectId] = $projectId;
|
||||
$dailyProjectStatistic = new DailyStatistic($begin, $end, $user);
|
||||
foreach ($projectValues['activities'] as $activityId => $activityValues) {
|
||||
$activityIds[$activityId] = $activityId;
|
||||
if (!isset($data[$projectId]['duration'])) {
|
||||
$data[$projectId]['duration'] = 0;
|
||||
}
|
||||
if (!isset($data[$projectId]['activities'][$activityId]['duration'])) {
|
||||
$data[$projectId]['activities'][$activityId]['duration'] = 0;
|
||||
}
|
||||
/** @var StatisticDate $day */
|
||||
foreach ($activityValues['days']->getDays() as $day) {
|
||||
$statDay = $dailyProjectStatistic->getDayByDateTime($day->getDate());
|
||||
$statDay->setTotalDuration($statDay->getTotalDuration() + $day->getDuration());
|
||||
$data[$projectId]['duration'] = $data[$projectId]['duration'] + $day->getDuration();
|
||||
$data[$projectId]['activities'][$activityId]['duration'] = $data[$projectId]['activities'][$activityId]['duration'] + $day->getDuration();
|
||||
}
|
||||
}
|
||||
$data[$projectId]['days'] = $dailyProjectStatistic;
|
||||
}
|
||||
|
||||
$activities = $this->activityRepository->findByIds($activityIds);
|
||||
foreach ($activities as $activity) {
|
||||
$activityIds[$activity->getId()] = $activity;
|
||||
}
|
||||
|
||||
foreach ($data as $projectId => $projectValues) {
|
||||
foreach ($projectValues['activities'] as $activityId => $activityValues) {
|
||||
$data[$projectId]['activities'][$activityId]['activity'] = $activityIds[$activityId];
|
||||
}
|
||||
}
|
||||
|
||||
$projects = $this->projectRepository->findByIds($projectIds);
|
||||
foreach ($projects as $project) {
|
||||
$data[$project->getId()]['project'] = $project;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,241 +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\Controller\Reporting;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Controller\AbstractController;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\MonthlyUserList;
|
||||
use App\Reporting\MonthlyUserListForm;
|
||||
use App\Reporting\WeeklyUserList;
|
||||
use App\Reporting\WeeklyUserListForm;
|
||||
use App\Reporting\YearlyUserList;
|
||||
use App\Reporting\YearlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
final class ReportUsersListController extends AbstractController
|
||||
{
|
||||
private $userRepository;
|
||||
|
||||
public function __construct(UserRepository $userRepository)
|
||||
{
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/yearly_users_list", name="report_yearly_users", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function yearlyUsersList(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService): Response
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $this->userRepository->getUsersForQuery($query);
|
||||
$defaultDate = $dateTimeFactory->createDateTime('01 january this year 00:00:00');
|
||||
|
||||
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
|
||||
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
|
||||
}
|
||||
|
||||
$values = new YearlyUserList();
|
||||
$values->setDate(clone $defaultDate);
|
||||
|
||||
$form = $this->createForm(YearlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
// there is a potential edge case bug for financial years:
|
||||
// the last month will be skipped, if the financial year started on a different day than the first
|
||||
$end = $dateTimeFactory->createEndOfFinancialYear($start);
|
||||
|
||||
$monthStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($monthStats)) {
|
||||
$monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return $this->render('reporting/report_user_list_monthly.html.twig', [
|
||||
'report_title' => 'report_yearly_users',
|
||||
'box_id' => 'yearly-user-list-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'stats' => $monthStats,
|
||||
'hasData' => $hasData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/monthly_users_list", name="report_monthly_users", methods={"GET","POST"})
|
||||
*/
|
||||
public function monthlyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $this->userRepository->getUsersForQuery($query);
|
||||
|
||||
$values = new MonthlyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 month');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 month');
|
||||
|
||||
$dayStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($dayStats)) {
|
||||
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return $this->render('reporting/report_user_list.html.twig', [
|
||||
'report_title' => 'report_monthly_users',
|
||||
'box_id' => 'monthly-user-list-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'subReportDate' => $values->getDate(),
|
||||
'subReportRoute' => 'report_user_month',
|
||||
'stats' => $dayStats,
|
||||
'hasData' => $hasData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/weekly_users_list", name="report_weekly_users", methods={"GET","POST"})
|
||||
*/
|
||||
public function weeklyUsersList(Request $request, TimesheetStatisticService $statisticService): Response
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $this->userRepository->getUsersForQuery($query);
|
||||
|
||||
$values = new WeeklyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeeklyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
|
||||
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 week');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 week');
|
||||
|
||||
$dayStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($dayStats)) {
|
||||
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return $this->render('reporting/report_user_list.html.twig', [
|
||||
'report_title' => 'report_weekly_users',
|
||||
'box_id' => 'weekly-user-list-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'subReportDate' => $values->getDate(),
|
||||
'subReportRoute' => 'report_user_week',
|
||||
'stats' => $dayStats,
|
||||
'hasData' => $hasData,
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
src/Controller/Reporting/ReportUsersMonthController.php
Normal file
129
src/Controller/Reporting/ReportUsersMonthController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\MonthlyUserList;
|
||||
use App\Reporting\MonthlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
final class ReportUsersMonthController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/month", name="report_monthly_users", methods={"GET","POST"})
|
||||
*/
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'reporting/report_user_list.html.twig',
|
||||
$this->getData($request, $statisticService, $userRepository)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/month_export", name="report_monthly_users_export", methods={"GET","POST"})
|
||||
*/
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-monthly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
|
||||
$values = new MonthlyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 month');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 month');
|
||||
|
||||
$dayStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($dayStats)) {
|
||||
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'period_attribute' => 'days',
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_monthly_users',
|
||||
'box_id' => 'monthly-user-list-reporting-box',
|
||||
'export_route' => 'report_monthly_users_export',
|
||||
'form' => $form->createView(),
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'decimal' => $values->isDecimal(),
|
||||
'subReportDate' => $values->getDate(),
|
||||
'subReportRoute' => 'report_user_month',
|
||||
'stats' => $dayStats,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
}
|
||||
126
src/Controller/Reporting/ReportUsersWeekController.php
Normal file
126
src/Controller/Reporting/ReportUsersWeekController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\WeeklyUserList;
|
||||
use App\Reporting\WeeklyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
final class ReportUsersWeekController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/week", name="report_weekly_users", methods={"GET","POST"})
|
||||
*/
|
||||
public function report(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'reporting/report_user_list.html.twig',
|
||||
$this->getData($request, $statisticService, $userRepository)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/week_export", name="report_weekly_users_export", methods={"GET","POST"})
|
||||
*/
|
||||
public function export(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-weekly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
|
||||
$values = new WeeklyUserList();
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeeklyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
|
||||
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 week');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 week');
|
||||
|
||||
$dayStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$dayStats = $statisticService->getDailyStatistics($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($dayStats)) {
|
||||
$dayStats = [new DailyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'period_attribute' => 'days',
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_weekly_users',
|
||||
'box_id' => 'weekly-user-list-reporting-box',
|
||||
'export_route' => 'report_weekly_users_export',
|
||||
'form' => $form->createView(),
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
'decimal' => $values->isDecimal(),
|
||||
'subReportDate' => $values->getDate(),
|
||||
'subReportRoute' => 'report_user_week',
|
||||
'stats' => $dayStats,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
}
|
||||
133
src/Controller/Reporting/ReportUsersYearController.php
Normal file
133
src/Controller/Reporting/ReportUsersYearController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Controller\AbstractController;
|
||||
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
|
||||
use App\Export\Spreadsheet\Writer\XlsxWriter;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\YearlyUserList;
|
||||
use App\Reporting\YearlyUserListForm;
|
||||
use App\Repository\Query\UserQuery;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\TimesheetStatisticService;
|
||||
use Exception;
|
||||
use PhpOffice\PhpSpreadsheet\Reader\Html;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/users")
|
||||
* @Security("is_granted('view_reporting') and is_granted('view_other_reporting') and is_granted('view_other_timesheet')")
|
||||
*/
|
||||
final class ReportUsersYearController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/year", name="report_yearly_users", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function report(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
return $this->render(
|
||||
'reporting/report_user_list_monthly.html.twig',
|
||||
$this->getData($request, $systemConfiguration, $statisticService, $userRepository)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/year_export", name="report_yearly_users_export", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function export(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): Response
|
||||
{
|
||||
$data = $this->getData($request, $systemConfiguration, $statisticService, $userRepository);
|
||||
|
||||
$content = $this->container->get('twig')->render('reporting/report_user_list_monthly_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
$spreadsheet = $reader->loadFromString($content);
|
||||
|
||||
$writer = new BinaryFileResponseWriter(new XlsxWriter(), 'kimai-export-users-yearly');
|
||||
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration, TimesheetStatisticService $statisticService, UserRepository $userRepository): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory();
|
||||
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
$defaultDate = $dateTimeFactory->createStartOfYear();
|
||||
|
||||
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
|
||||
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
|
||||
}
|
||||
|
||||
$values = new YearlyUserList();
|
||||
$values->setDate(clone $defaultDate);
|
||||
|
||||
$form = $this->createForm(YearlyUserListForm::class, $values, [
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($form->isSubmitted() && !$form->isValid()) {
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
// there is a potential edge case bug for financial years:
|
||||
// the last month will be skipped, if the financial year started on a different day than the first
|
||||
$end = $dateTimeFactory->createEndOfFinancialYear($start);
|
||||
|
||||
$monthStats = [];
|
||||
$hasData = true;
|
||||
|
||||
if (!empty($allUsers)) {
|
||||
$monthStats = $statisticService->getMonthlyStats($start, $end, $allUsers);
|
||||
}
|
||||
|
||||
if (empty($monthStats)) {
|
||||
$monthStats = [new MonthlyStatistic($start, $end, $currentUser)];
|
||||
$hasData = false;
|
||||
}
|
||||
|
||||
return [
|
||||
'query' => $values,
|
||||
'period_attribute' => 'months',
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_yearly_users',
|
||||
'box_id' => 'yearly-user-list-reporting-box',
|
||||
'export_route' => 'report_yearly_users_export',
|
||||
'decimal' => $values->isDecimal(),
|
||||
'form' => $form->createView(),
|
||||
'stats' => $monthStats,
|
||||
'hasData' => $hasData,
|
||||
];
|
||||
}
|
||||
}
|
||||
101
src/Controller/Reporting/UserMonthController.php
Normal file
101
src/Controller/Reporting/UserMonthController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\MonthByUser;
|
||||
use App\Reporting\MonthByUserForm;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/user")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
final class UserMonthController extends AbstractUserReportController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/month", name="report_user_month", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function monthByUser(Request $request): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new MonthByUser();
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
$form = $this->createForm(MonthByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($values->getUser() === null) {
|
||||
$values->setUser($currentUser);
|
||||
}
|
||||
|
||||
if ($currentUser !== $values->getUser() && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
$start->modify('first day of 00:00:00');
|
||||
|
||||
$end = clone $start;
|
||||
$end->modify('last day of 23:59:59');
|
||||
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previousMonth = clone $start;
|
||||
$previousMonth->modify('-1 month');
|
||||
|
||||
$nextMonth = clone $start;
|
||||
$nextMonth->modify('+1 month');
|
||||
|
||||
$data = $this->prepareReport($start, $end, $selectedUser);
|
||||
|
||||
return [
|
||||
'decimal' => $values->isDecimal(),
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_user_month',
|
||||
'box_id' => 'user-month-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'rows' => $data,
|
||||
'period' => new DailyStatistic($start, $end, $selectedUser),
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $nextMonth,
|
||||
'previous' => $previousMonth,
|
||||
];
|
||||
}
|
||||
}
|
||||
97
src/Controller/Reporting/UserWeekController.php
Normal file
97
src/Controller/Reporting/UserWeekController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\DailyStatistic;
|
||||
use App\Reporting\WeekByUser;
|
||||
use App\Reporting\WeekByUserForm;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/user")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
final class UserWeekController extends AbstractUserReportController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/week", name="report_user_week", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function weekByUser(Request $request): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user.html.twig', $this->getData($request));
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new WeekByUser();
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
$form = $this->createForm(WeekByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($values->getUser() === null) {
|
||||
$values->setUser($currentUser);
|
||||
}
|
||||
|
||||
if ($currentUser !== $values->getUser() && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
}
|
||||
|
||||
$start = $dateTimeFactory->getStartOfWeek($values->getDate());
|
||||
$end = $dateTimeFactory->getEndOfWeek($values->getDate());
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 week');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 week');
|
||||
|
||||
$data = $this->prepareReport($start, $end, $selectedUser);
|
||||
|
||||
return [
|
||||
'decimal' => $values->isDecimal(),
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_user_week',
|
||||
'box_id' => 'user-week-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'period' => new DailyStatistic($start, $end, $selectedUser),
|
||||
'rows' => $data,
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
];
|
||||
}
|
||||
}
|
||||
120
src/Controller/Reporting/UserYearController.php
Normal file
120
src/Controller/Reporting/UserYearController.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?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\Controller\Reporting;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Model\DateStatisticInterface;
|
||||
use App\Model\MonthlyStatistic;
|
||||
use App\Reporting\YearByUser;
|
||||
use App\Reporting\YearByUserForm;
|
||||
use DateTime;
|
||||
use Exception;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
/**
|
||||
* @Route(path="/reporting/user")
|
||||
* @Security("is_granted('view_reporting')")
|
||||
*/
|
||||
final class UserYearController extends AbstractUserReportController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/year", name="report_user_year", methods={"GET","POST"})
|
||||
*
|
||||
* @param Request $request
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
|
||||
}
|
||||
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new YearByUser();
|
||||
$values->setUser($currentUser);
|
||||
|
||||
$defaultDate = $dateTimeFactory->createStartOfYear();
|
||||
|
||||
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
|
||||
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
|
||||
}
|
||||
|
||||
$values->setDate(clone $defaultDate);
|
||||
|
||||
$form = $this->createForm(YearByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
'timezone' => $dateTimeFactory->getTimezone()->getName(),
|
||||
'start_date' => $values->getDate(),
|
||||
]);
|
||||
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($values->getUser() === null) {
|
||||
$values->setUser($currentUser);
|
||||
}
|
||||
|
||||
if ($currentUser !== $values->getUser() && !$canChangeUser) {
|
||||
throw new AccessDeniedException('User is not allowed to see other users timesheet');
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
$start = $values->getDate();
|
||||
// there is a potential edge case bug for financial years:
|
||||
// the last month will be skipped, if the financial year started on a different day than the first
|
||||
$end = $dateTimeFactory->createEndOfFinancialYear($start);
|
||||
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previous = clone $start;
|
||||
$previous->modify('-1 year');
|
||||
|
||||
$next = clone $start;
|
||||
$next->modify('+1 year');
|
||||
|
||||
$data = $this->prepareReport($start, $end, $selectedUser);
|
||||
|
||||
return [
|
||||
'decimal' => $values->isDecimal(),
|
||||
'dataType' => $values->getSumType(),
|
||||
'report_title' => 'report_user_year',
|
||||
'box_id' => 'user-year-reporting-box',
|
||||
'form' => $form->createView(),
|
||||
'period' => new MonthlyStatistic($start, $end, $selectedUser),
|
||||
'rows' => $data,
|
||||
'user' => $selectedUser,
|
||||
'current' => $start,
|
||||
'next' => $next,
|
||||
'previous' => $previous,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getStatisticDataRaw(DateTime $begin, DateTime $end, User $user): array
|
||||
{
|
||||
return $this->statisticService->getMonthlyStatisticsGrouped($begin, $end, [$user]);
|
||||
}
|
||||
|
||||
protected function createStatisticModel(DateTime $begin, DateTime $end, User $user): DateStatisticInterface
|
||||
{
|
||||
return new MonthlyStatistic($begin, $end, $user);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,10 @@ final class SecurityController extends AbstractController
|
||||
*/
|
||||
public function loginAction(Request $request): Response
|
||||
{
|
||||
if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
|
||||
return $this->redirectToRoute('homepage');
|
||||
}
|
||||
|
||||
/** @var SessionInterface $session */
|
||||
$session = $request->getSession();
|
||||
|
||||
|
||||
@@ -14,12 +14,16 @@ use App\Event\SystemConfigurationEvent;
|
||||
use App\Form\Model\Configuration;
|
||||
use App\Form\Model\SystemConfiguration as SystemConfigurationModel;
|
||||
use App\Form\SystemConfigurationForm;
|
||||
use App\Form\Type\ActivityTypePatternType;
|
||||
use App\Form\Type\ArrayToCommaStringType;
|
||||
use App\Form\Type\CalendarTitlePatternType;
|
||||
use App\Form\Type\CustomerTypePatternType;
|
||||
use App\Form\Type\DatePickerType;
|
||||
use App\Form\Type\DateTimeTextType;
|
||||
use App\Form\Type\DayTimeType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use App\Form\Type\MinuteIncrementType;
|
||||
use App\Form\Type\ProjectTypePatternType;
|
||||
use App\Form\Type\RoundingModeType;
|
||||
use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TimezoneType;
|
||||
@@ -266,8 +270,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$authentication = (new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_AUTHENTICATION)
|
||||
$authentication = (new SystemConfigurationModel('authentication'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('user.login')
|
||||
@@ -319,9 +322,8 @@ final class SystemConfigurationController extends AbstractController
|
||||
$authentication->getConfigurationByName('user.password_reset_token_ttl')->setEnabled(false);
|
||||
}
|
||||
|
||||
$configurationModels = [
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_TIMESHEET)
|
||||
return [
|
||||
(new SystemConfigurationModel('timesheet'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('timesheet.mode')
|
||||
@@ -382,13 +384,21 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setConstraints([
|
||||
new GreaterThanOrEqual(['value' => 0])
|
||||
]),
|
||||
(new Configuration())
|
||||
->setName('defaults.timesheet.billable')
|
||||
->setType(YesNoType::class)
|
||||
->setOptions(['help' => 'default_value_new', 'label' => 'label.billable']),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_LOCKDOWN)
|
||||
(new SystemConfigurationModel('quick_entry'))
|
||||
->setTranslation('quick_entry.title')
|
||||
->setTranslationDomain('messages')
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('quick_entry.recent_activities')
|
||||
->setType(IntegerType::class)
|
||||
->setTranslationDomain('system-configuration')
|
||||
->setRequired(false)
|
||||
->setConstraints([
|
||||
new Range(['min' => 0, 'max' => 20]),
|
||||
]),
|
||||
]),
|
||||
(new SystemConfigurationModel('lockdown_period'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('timesheet.rules.lockdown_period_start')
|
||||
@@ -417,8 +427,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setConstraints([new DateTimeFormat()])
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_ROUNDING)
|
||||
(new SystemConfigurationModel('rounding'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('timesheet.rounding.default.mode')
|
||||
@@ -450,8 +459,9 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setType(WeekDaysType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE)
|
||||
(new SystemConfigurationModel('invoice'))
|
||||
->setTranslation('invoices')
|
||||
->setTranslationDomain('messages')
|
||||
->setConfiguration([
|
||||
// TODO that should be a custom type with validation
|
||||
(new Configuration())
|
||||
@@ -468,8 +478,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
$authentication,
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
|
||||
(new SystemConfigurationModel('customer'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('defaults.customer.timezone')
|
||||
@@ -487,9 +496,26 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setLabel('currency')
|
||||
->setType(CurrencyType::class)
|
||||
->setOptions(['help' => 'default_value_new']),
|
||||
(new Configuration())
|
||||
->setName('customer.choice_pattern')
|
||||
->setLabel('choice_pattern')
|
||||
->setType(CustomerTypePatternType::class),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_USER)
|
||||
(new SystemConfigurationModel('project'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('project.choice_pattern')
|
||||
->setLabel('choice_pattern')
|
||||
->setType(ProjectTypePatternType::class),
|
||||
]),
|
||||
(new SystemConfigurationModel('activity'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('activity.choice_pattern')
|
||||
->setLabel('choice_pattern')
|
||||
->setType(ActivityTypePatternType::class),
|
||||
]),
|
||||
(new SystemConfigurationModel('user'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('defaults.user.timezone')
|
||||
@@ -518,8 +544,7 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setType(CheckboxType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_THEME)
|
||||
(new SystemConfigurationModel('theme'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('theme.autocomplete_chars')
|
||||
@@ -557,8 +582,9 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setType(CheckboxType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_CALENDAR)
|
||||
(new SystemConfigurationModel('calendar'))
|
||||
->setTranslation('calendar')
|
||||
->setTranslationDomain('messages')
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('calendar.week_numbers')
|
||||
@@ -598,9 +624,12 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setTranslationDomain('system-configuration')
|
||||
->setType(IntegerType::class)
|
||||
->setConstraints([new Range(['min' => 0, 'max' => 20]), new NotNull()]),
|
||||
(new Configuration())
|
||||
->setName('calendar.title_pattern')
|
||||
->setTranslationDomain('system-configuration')
|
||||
->setType(CalendarTitlePatternType::class),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_BRANDING)
|
||||
(new SystemConfigurationModel('branding'))
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('theme.branding.logo')
|
||||
@@ -632,7 +661,5 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setOptions(['input' => 'string']),
|
||||
]),
|
||||
];
|
||||
|
||||
return $configurationModels;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||
|
||||
/**
|
||||
* @Route(path="/admin/teams")
|
||||
@@ -81,11 +83,19 @@ final class TeamController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/{id}/duplicate", name="team_duplicate", methods={"GET", "POST"})
|
||||
* @Route(path="/{id}/duplicate/{token}", name="team_duplicate", methods={"GET", "POST"})
|
||||
* @Security("is_granted('edit', team) and is_granted('create_team')")
|
||||
*/
|
||||
public function duplicateTeam(Team $team, Request $request)
|
||||
public function duplicateTeam(Team $team, string $token, CsrfTokenManagerInterface $csrfTokenManager)
|
||||
{
|
||||
if (!$csrfTokenManager->isTokenValid(new CsrfToken('team.duplicate', $token))) {
|
||||
$this->flashError('action.csrf.error');
|
||||
|
||||
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
|
||||
}
|
||||
|
||||
$csrfTokenManager->refreshToken('team.duplicate');
|
||||
|
||||
$newTeam = clone $team;
|
||||
$newTeam->setName($team->getName() . ' [COPY]');
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
protected function export(Request $request, ServiceExport $serviceExport): Response
|
||||
{
|
||||
$query = $this->createDefaultQuery();
|
||||
$query->setOrder(TimesheetQuery::ORDER_ASC);
|
||||
|
||||
$form = $this->getExportForm($query);
|
||||
|
||||
@@ -455,6 +456,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
|
||||
'method' => 'POST',
|
||||
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
|
||||
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
|
||||
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
|
||||
'include_user' => $this->includeUserInForms('multi'),
|
||||
]);
|
||||
@@ -482,6 +484,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
'action' => $action,
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_billable' => $this->isGranted('edit_billable', $entry),
|
||||
'include_user' => $this->includeUserInForms('create'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
@@ -510,6 +513,7 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
]),
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_billable' => $this->isGranted('edit_billable', $entry),
|
||||
'include_user' => $this->includeUserInForms('edit'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
@@ -549,6 +553,11 @@ abstract class TimesheetAbstractController extends AbstractController
|
||||
return 'edit_export_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditBillable(): string
|
||||
{
|
||||
return 'edit_billable_own_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_own_timesheet';
|
||||
|
||||
@@ -153,8 +153,9 @@ class TimesheetTeamController extends TimesheetAbstractController
|
||||
|
||||
return $this->createForm(TimesheetMultiUserEditForm::class, $entry, [
|
||||
'action' => $this->generateUrl('admin_timesheet_create_multiuser'),
|
||||
'include_rate' => $this->isGranted('edit_rate', $entry),
|
||||
'include_exported' => $this->isGranted('edit_export', $entry),
|
||||
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
|
||||
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
|
||||
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
|
||||
'include_user' => $this->includeUserInForms('create'),
|
||||
'allow_begin_datetime' => $mode->canEditBegin(),
|
||||
'allow_end_datetime' => $mode->canEditEnd(),
|
||||
@@ -205,6 +206,11 @@ class TimesheetTeamController extends TimesheetAbstractController
|
||||
return 'edit_export_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditBillable(): string
|
||||
{
|
||||
return 'edit_billable_other_timesheet';
|
||||
}
|
||||
|
||||
protected function getPermissionEditRate(): string
|
||||
{
|
||||
return 'edit_rate_other_timesheet';
|
||||
|
||||
@@ -136,7 +136,9 @@ final class UserController extends AbstractController
|
||||
$user->setTimezone($firstUser->getTimezone());
|
||||
|
||||
$editForm = $this->getCreateUserForm($user);
|
||||
$editForm->get('create_more')->setData(true);
|
||||
if ($editForm->has('create_more')) {
|
||||
$editForm->get('create_more')->setData(true);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('user/create.html.twig', [
|
||||
|
||||
47
src/DataFixtures/AllFixtures.php
Normal file
47
src/DataFixtures/AllFixtures.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?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\DataFixtures;
|
||||
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
/**
|
||||
* This fixture makes sure that all data is loaded.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class AllFixtures extends Fixture implements DependentFixtureInterface
|
||||
{
|
||||
/**
|
||||
* @return class-string[]
|
||||
*/
|
||||
public function getDependencies()
|
||||
{
|
||||
return [
|
||||
UserFixtures::class,
|
||||
CustomerFixtures::class,
|
||||
TagFixtures::class,
|
||||
TimesheetFixtures::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function load(ObjectManager $manager)
|
||||
{
|
||||
// this is a fake fixture class, it only exists to make developers life easier
|
||||
// if we use the DependentFixtureInterface on the TimesheetFixture directly,
|
||||
// we cannot load
|
||||
// bin/console doctrine:fixtures:load --append --group=timesheet
|
||||
// without executing all dependent fixtures
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,9 @@ class TagFixtures extends Fixture
|
||||
$tag = new Tag();
|
||||
|
||||
$tagName = null;
|
||||
if ($i % 2 == 9) {
|
||||
$tagName = $faker->companyEmail;
|
||||
} elseif ($i % 2 == 8) {
|
||||
if ($i % 18 == 0) {
|
||||
$tagName = $faker->firstName;
|
||||
} elseif ($i % 2 == 7) {
|
||||
} elseif ($i % 7 == 0) {
|
||||
$tagName = $faker->lastName;
|
||||
} elseif ($i % 5 == 0) {
|
||||
$tagName = $faker->city;
|
||||
|
||||
@@ -13,7 +13,6 @@ use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Faker\Factory;
|
||||
|
||||
@@ -26,20 +25,15 @@ use Faker\Factory;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TeamFixtures extends Fixture implements DependentFixtureInterface
|
||||
class TeamFixtures extends Fixture
|
||||
{
|
||||
public const AMOUNT_TEAMS = 10;
|
||||
public const MAX_USERS_PER_TEAM = 15;
|
||||
public const MAX_PROJECTS_PER_TEAM = 5;
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getDependencies()
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return [
|
||||
UserFixtures::class,
|
||||
];
|
||||
return ['users', 'team'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,6 @@ use App\Entity\UserPreference;
|
||||
use App\Timesheet\Util;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Faker\Factory;
|
||||
|
||||
@@ -26,20 +25,19 @@ use Faker\Factory;
|
||||
* Defines the sample data to load in the database when running the unit and
|
||||
* functional tests or while development.
|
||||
*
|
||||
* Execute this command to load the data:
|
||||
* Execute this command to load add fixture data:
|
||||
* bin/console doctrine:fixtures:load
|
||||
*
|
||||
* Or simply append it to your running installation:
|
||||
* bin/console doctrine:fixtures:load --append --group=timesheet
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class TimesheetFixtures extends Fixture implements DependentFixtureInterface, FixtureGroupInterface
|
||||
class TimesheetFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const MIN_TIMESHEETS_PER_USER = 50;
|
||||
public const MAX_TIMESHEETS_PER_USER = 500;
|
||||
public const MAX_TIMESHEETS_TOTAL = 5000;
|
||||
public const MAX_RUNNING_TIMESHEETS_PER_USER = 1;
|
||||
public const TIMERANGE_BEGIN_DAYS = 1; // yesterday
|
||||
public const TIMERANGE_END_DAYS = 1095; // 3 years
|
||||
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
|
||||
public const MAX_TIMESHEETS_TOTAL = 200;
|
||||
public const MIN_MINUTES_PER_ENTRY = 15;
|
||||
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
|
||||
public const MAX_DESCRIPTION_LENGTH = 200;
|
||||
@@ -49,23 +47,16 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
|
||||
|
||||
public const BATCH_SIZE = 100;
|
||||
|
||||
/**
|
||||
* @return class-string[]
|
||||
*/
|
||||
public function getDependencies()
|
||||
{
|
||||
return [
|
||||
UserFixtures::class,
|
||||
CustomerFixtures::class,
|
||||
TagFixtures::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['timesheet'];
|
||||
}
|
||||
|
||||
public function getRandomFirstDay(): \DateTime
|
||||
{
|
||||
return new \DateTime(rand(-1095, 14) . ' days');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -95,7 +86,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
|
||||
if ($i % 3 === 0) {
|
||||
$description = $faker->realText($faker->numberBetween(10, self::MAX_DESCRIPTION_LENGTH));
|
||||
} elseif ($i % 7 === 0) {
|
||||
$description = substr($faker->text, 0, self::MAX_DESCRIPTION_LENGTH);
|
||||
$description = substr($faker->text(), 0, self::MAX_DESCRIPTION_LENGTH);
|
||||
}
|
||||
|
||||
$entry = $this->createTimesheetEntry(
|
||||
@@ -117,8 +108,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
|
||||
}
|
||||
|
||||
// create active recordings for test user
|
||||
$activeEntries = rand(0, self::MAX_RUNNING_TIMESHEETS_PER_USER);
|
||||
for ($i = 0; $i < $activeEntries; $i++) {
|
||||
if (rand(0, 10) >= 5) {
|
||||
$entry = $this->createTimesheetEntry(
|
||||
$user,
|
||||
$activities[array_rand($activities)],
|
||||
@@ -218,8 +208,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
|
||||
|
||||
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, bool $setEndDate)
|
||||
{
|
||||
$start = new \DateTime();
|
||||
$start = $start->modify('- ' . (rand(self::TIMERANGE_BEGIN_DAYS, self::TIMERANGE_END_DAYS)) . ' days');
|
||||
$start = $this->getRandomFirstDay();
|
||||
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
|
||||
$start->setTimezone(new \DateTimeZone($user->getPreferenceValue(UserPreference::TIMEZONE, date_default_timezone_get())));
|
||||
|
||||
@@ -246,7 +235,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface, Fi
|
||||
} else {
|
||||
// running entries should be short
|
||||
$newBegin = clone $entry->getBegin();
|
||||
$newBegin->setTimestamp(time())->modify('- ' . rand(10, self::TIMERANGE_RUNNING) . ' minutes');
|
||||
$newBegin->setTimestamp(time())->modify('- ' . rand(self::MIN_MINUTES_PER_ENTRY, self::MAX_MINUTES_PER_ENTRY) . ' minutes');
|
||||
$entry->setBegin($newBegin);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\DataFixtures;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
use Faker\Factory;
|
||||
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
@@ -25,7 +26,7 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class UserFixtures extends Fixture
|
||||
class UserFixtures extends Fixture implements FixtureGroupInterface
|
||||
{
|
||||
public const DEFAULT_PASSWORD = 'kitten';
|
||||
public const DEFAULT_API_TOKEN = 'api_kitten';
|
||||
@@ -63,6 +64,11 @@ class UserFixtures extends Fixture
|
||||
$this->loadTestUsers($manager);
|
||||
}
|
||||
|
||||
public static function getGroups(): array
|
||||
{
|
||||
return ['user', 'users'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default users for all test cases
|
||||
*
|
||||
|
||||
@@ -100,6 +100,7 @@ class AppExtension extends Extension
|
||||
$container->setParameter('kimai.i18n_domains', $localTranslations);
|
||||
|
||||
// this should happen always at the end, so bundles do not mess with the base configuration
|
||||
/* @phpstan-ignore-next-line */
|
||||
if ($container->hasParameter('kimai.bundles.config')) {
|
||||
$bundleConfig = $container->getParameter('kimai.bundles.config');
|
||||
if (!\is_array($bundleConfig)) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class Configuration implements ConfigurationInterface
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getConfigTreeBuilder()
|
||||
public function getConfigTreeBuilder(): TreeBuilder
|
||||
{
|
||||
$treeBuilder = new TreeBuilder('kimai');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -69,13 +69,32 @@ class Configuration implements ConfigurationInterface
|
||||
->append($this->getPermissionsNode())
|
||||
->append($this->getLdapNode())
|
||||
->append($this->getSamlNode())
|
||||
->append($this->getQuickEntryNode())
|
||||
->end()
|
||||
->end();
|
||||
|
||||
return $treeBuilder;
|
||||
}
|
||||
|
||||
protected function getTimesheetNode()
|
||||
private function getQuickEntryNode()
|
||||
{
|
||||
$builder = new TreeBuilder('quick_entry');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
$node = $builder->getRootNode();
|
||||
|
||||
$node
|
||||
->addDefaultsIfNotSet()
|
||||
->children()
|
||||
->integerNode('recent_activities')
|
||||
->defaultValue(5)
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
private function getTimesheetNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('timesheet');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -259,7 +278,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getInvoiceNode()
|
||||
private function getInvoiceNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('invoice');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -292,7 +311,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getExportNode()
|
||||
private function getExportNode()
|
||||
{
|
||||
$builder = new TreeBuilder('export');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -319,7 +338,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getLanguagesNode()
|
||||
private function getLanguagesNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('languages');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -349,7 +368,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getCalendarNode()
|
||||
private function getCalendarNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('calendar');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -410,13 +429,17 @@ class Configuration implements ConfigurationInterface
|
||||
->thenInvalid('The dragdrop_amount must be between 0 and 20')
|
||||
->end()
|
||||
->end()
|
||||
->enumNode('title_pattern')
|
||||
->values(['{activity}', '{project}', '{customer}', '{description}'])
|
||||
->defaultValue('{activity}')
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getThemeNode()
|
||||
private function getThemeNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('theme');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -506,7 +529,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getIndustryNode()
|
||||
private function getIndustryNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('industry');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -522,7 +545,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getCompanyNode()
|
||||
private function getCompanyNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('company');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -538,7 +561,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getUserNode()
|
||||
private function getUserNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('user');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -568,7 +591,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getWidgetsNode()
|
||||
private function getWidgetsNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('widgets');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -595,7 +618,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getDashboardNode()
|
||||
private function getDashboardNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('dashboard');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -624,7 +647,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getDefaultsNode()
|
||||
private function getDefaultsNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('defaults');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -662,7 +685,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getPermissionsNode()
|
||||
private function getPermissionsNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('permissions');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
@@ -712,7 +735,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getLdapNode()
|
||||
private function getLdapNode(): ArrayNodeDefinition
|
||||
{
|
||||
$treeBuilder = new TreeBuilder('ldap');
|
||||
$node = $treeBuilder->getRootNode();
|
||||
@@ -835,7 +858,7 @@ class Configuration implements ConfigurationInterface
|
||||
return $node;
|
||||
}
|
||||
|
||||
protected function getSamlNode()
|
||||
private function getSamlNode(): ArrayNodeDefinition
|
||||
{
|
||||
$builder = new TreeBuilder('saml');
|
||||
/** @var ArrayNodeDefinition $node */
|
||||
|
||||
@@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
* }
|
||||
* )
|
||||
*
|
||||
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment"})
|
||||
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment", "billable"})
|
||||
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
|
||||
*/
|
||||
class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
@@ -99,10 +99,10 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
/**
|
||||
* Description of this activity
|
||||
*
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Activity_Entity"})
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.comment")
|
||||
*
|
||||
@@ -123,6 +123,18 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $visible = true;
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.billable", type="boolean")
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* Meta fields
|
||||
*
|
||||
@@ -227,6 +239,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
return $this->visible;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): void
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
public function isBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|MetaTableTypeInterface[]
|
||||
*/
|
||||
@@ -261,6 +283,20 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return bool|int|string|null
|
||||
*/
|
||||
public function getMetaFieldValue(string $name)
|
||||
{
|
||||
$field = $this->getMetaField($name);
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $field->getValue();
|
||||
}
|
||||
|
||||
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
|
||||
{
|
||||
if (null === ($current = $this->getMetaField($meta->getName()))) {
|
||||
@@ -325,7 +361,7 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
|
||||
|
||||
$currentMeta = $this->meta;
|
||||
$this->meta = new ArrayCollection();
|
||||
/** @var ProjectMeta $meta */
|
||||
/** @var ActivityMeta $meta */
|
||||
foreach ($currentMeta as $meta) {
|
||||
$newMeta = clone $meta;
|
||||
$newMeta->setEntity($this);
|
||||
|
||||
@@ -51,7 +51,7 @@ trait BudgetTrait
|
||||
* - null = default / full time
|
||||
* - month = monthly budget
|
||||
*
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Activity_Entity", "Project_Entity", "Customer_Entity"})
|
||||
|
||||
@@ -20,7 +20,7 @@ trait ColorTrait
|
||||
/**
|
||||
* The assigned color in HTML hex format, eg. #dd1d00
|
||||
*
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
@@ -32,9 +32,6 @@ trait ColorTrait
|
||||
*/
|
||||
private $color = null;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getColor(): ?string
|
||||
{
|
||||
if ($this->color === Constants::DEFAULT_COLOR) {
|
||||
@@ -49,9 +46,6 @@ trait ColorTrait
|
||||
return null !== $this->color && $this->color !== Constants::DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $color
|
||||
*/
|
||||
public function setColor(?string $color = null): void
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
@@ -30,10 +30,8 @@ class Configuration
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @phpstan-ignore-next-line
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
@@ -42,12 +40,10 @@ class Configuration
|
||||
* @Assert\Length(min=2, max=100, allowEmptyString=false)
|
||||
*/
|
||||
private $name;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @ORM\Column(name="value", type="string", length=1024, nullable=true)
|
||||
* @Assert\Length(max=1024, allowEmptyString=true)
|
||||
* @ORM\Column(name="value", type="text", nullable=true)
|
||||
*/
|
||||
private $value;
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
*
|
||||
* @Serializer\ExclusionPolicy("all")
|
||||
*
|
||||
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
|
||||
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
|
||||
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
|
||||
*/
|
||||
class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
@@ -64,7 +64,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $name;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
@@ -76,10 +76,10 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $number;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.comment")
|
||||
*
|
||||
@@ -99,7 +99,19 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $visible = true;
|
||||
/**
|
||||
* @var string
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @Exporter\Expose(label="label.billable", type="boolean")
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $billable = true;
|
||||
/**
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -111,7 +123,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $company;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -123,7 +135,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $vatId;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -135,7 +147,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $contact;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -174,7 +186,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $currency = self::DEFAULT_CURRENCY;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -186,7 +198,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $phone;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -198,7 +210,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $fax;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -214,7 +226,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*
|
||||
* Limited via RFC to 254 chars
|
||||
*
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -226,7 +238,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
*/
|
||||
private $email;
|
||||
/**
|
||||
* @var string
|
||||
* @var string|null
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Customer_Entity"})
|
||||
@@ -353,6 +365,16 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
return $this->visible;
|
||||
}
|
||||
|
||||
public function setBillable(bool $billable): void
|
||||
{
|
||||
$this->billable = $billable;
|
||||
}
|
||||
|
||||
public function isBillable(): bool
|
||||
{
|
||||
return $this->billable;
|
||||
}
|
||||
|
||||
public function setCompany(?string $company): Customer
|
||||
{
|
||||
$this->company = $company;
|
||||
@@ -531,6 +553,20 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return bool|int|string|null
|
||||
*/
|
||||
public function getMetaFieldValue(string $name)
|
||||
{
|
||||
$field = $this->getMetaField($name);
|
||||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $field->getValue();
|
||||
}
|
||||
|
||||
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
|
||||
{
|
||||
if (null === ($current = $this->getMetaField($meta->getName()))) {
|
||||
@@ -595,7 +631,7 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
|
||||
|
||||
$currentMeta = $this->meta;
|
||||
$this->meta = new ArrayCollection();
|
||||
/** @var ProjectMeta $meta */
|
||||
/** @var CustomerMeta $meta */
|
||||
foreach ($currentMeta as $meta) {
|
||||
$newMeta = clone $meta;
|
||||
$newMeta->setEntity($this);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user