added project start and end date (#1303)

* added sortable js library
* activity in invoice is optional
* added javascript widget for paginated boxes
* fix activity dropdown for globals only
* added timesheet service to reduce code duplication
* use repository to query for teams in dropdowns
* added project validator
* validate project start and end against timesheet
* include begin and end in dynamic form requests for projects
* added timezone and language option to import flag, improve timesheet import speed
* deactivate cross-timezone filter
* add virtual fields to field order list
* composer update
* added param to ignore dates
* position loader icon fixed - fixes #1330
* permission problem when creating a new project - fixes #1340
* remove dev dependencies webserver and thanks bundle
* stop information leak (begin and end date) in duration mode - fixes #1307
* unify timesheet edit dialog for user and admins
* fix security issue, own rates exposed to unauthorized users in multi-update dialog
This commit is contained in:
Kevin Papst
2020-01-05 02:49:01 +01:00
committed by GitHub
parent 50383673ca
commit d6798eed1e
121 changed files with 2465 additions and 1439 deletions

View File

@@ -1,5 +1,5 @@
unreleased=true unreleased=true
future-release=1.6 future-release=1.7
exclude-labels=duplicate,question,invalid,wontfix,release exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt
issues-wo-labels=false issues-wo-labels=false

4
.gitignore vendored
View File

@@ -40,10 +40,6 @@ templates/invoice/renderer/.~lock*
/vendor/ /vendor/
###< symfony/framework-bundle ### ###< symfony/framework-bundle ###
###> symfony/web-server-bundle ###
.web-server-pid
###< symfony/web-server-bundle ###
###> phpunit/phpunit ### ###> phpunit/phpunit ###
/phpunit.xml /phpunit.xml
/.phpunit.result.cache /.phpunit.result.cache

View File

@@ -1,23 +1,45 @@
# Change Log # Change Log
## [1.7](https://github.com/kevinpapst/kimai2/tree/1.7) (2020-01-0?)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6.2...1.7)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.7)
## [1.6.2](https://github.com/kevinpapst/kimai2/tree/1.6.2) (2019-12-02)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6.1...1.6.2)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)
## [1.6.1](https://github.com/kevinpapst/kimai2/tree/1.6.1) (2019-11-26) ## [1.6.1](https://github.com/kevinpapst/kimai2/tree/1.6.1) (2019-11-26)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6...1.6.1) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.6...1.6.1)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1)
## [1.6](https://github.com/kevinpapst/kimai2/tree/1.6) (2019-11-24) ## [1.6](https://github.com/kevinpapst/kimai2/tree/1.6) (2019-11-24)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.5...1.6) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.5...1.6)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
## [1.5](https://github.com/kevinpapst/kimai2/tree/1.5) (2019-11-03) ## [1.5](https://github.com/kevinpapst/kimai2/tree/1.5) (2019-11-03)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.2...1.5) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.2...1.5)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.5)
## [1.4.2](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-14) ## [1.4.2](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-14)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.1...1.4.2) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4.1...1.4.2)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4.2)
## [1.4.1](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-07) ## [1.4.1](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-07)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4...1.4.1) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.4...1.4.1)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4.1)
## [1.4](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-03) ## [1.4](https://github.com/kevinpapst/kimai2/tree/1.4) (2019-10-03)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.3...1.4) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.3...1.4)
Release notes including the changelog can be found [here](https://github.com/kevinpapst/kimai2/releases/tag/1.4)
## [1.3](https://github.com/kevinpapst/kimai2/tree/1.3) (2019-09-11) ## [1.3](https://github.com/kevinpapst/kimai2/tree/1.3) (2019-09-11)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.2...1.3) [Full Changelog](https://github.com/kevinpapst/kimai2/compare/1.2...1.3)

View File

@@ -21,7 +21,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
### Requirements ### Requirements
- PHP 7.2 or higher - PHP 7.2 or higher
- Database (MySQL/MariaDB with timezone data, SQLite for development) - Database (MySQL/MariaDB with timezone data, SQLite for development)
- Webserver (nginx, Apache) - Webserver (nginx, Apache)
- A modern browser - A modern browser
- [Other libraries](https://www.kimai.org/download/) - [Other libraries](https://www.kimai.org/download/)

View File

@@ -10,8 +10,20 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7) ## [1.7](https://github.com/kevinpapst/kimai2/releases/tag/1.7)
New feature requires the timezone date within MySQL/MariaDB. **New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
This is especially important for self-hosted systems [where you need to import the data manually](https://mariadb.com/kb/en/library/mysql_tzinfo_to_sql/).
### Plugins
- Expenses plugin needs to be updated, due to incompatibilities in the underlying frameworks.
### Hosting
- New feature requires the timezone date within MySQL/MariaDB. This is especially important for self-hosted systems: [you need to import the data manually](https://mariadb.com/kb/en/library/mysql_tzinfo_to_sql/).
### Developer
- Projects now have a start and end date and the API will only return those, which are either unconfigured or currently active, you might want to reload the list of projects once the user entered begin and end datetime OR use the new `ignoreDates` parameter.
- Doctrine bundle was updated to v2, check your code for [the usage of RegistryInterface and ObjectManager](https://github.com/doctrine/DoctrineBundle/blob/master/UPGRADE-2.0.md)
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2) ## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)

View File

@@ -11,6 +11,7 @@ global.$ = global.jQuery = $;
require('bootstrap-sass'); require('bootstrap-sass');
require('jquery-slimscroll'); require('jquery-slimscroll');
require('select2'); require('select2');
require('select2/dist/js/i18n/ar'); require('select2/dist/js/i18n/ar');
require('select2/dist/js/i18n/cs'); require('select2/dist/js/i18n/cs');
@@ -53,6 +54,9 @@ require('moment/locale/zh-cn');
require('daterangepicker'); require('daterangepicker');
const Sortable = require('sortablejs/Sortable.min');
global.Sortable = Sortable;
// ------ AdminLTE framework ------ // ------ AdminLTE framework ------
require('./sass/admin-lte.scss'); require('./sass/admin-lte.scss');
require('admin-lte/dist/css/AdminLTE.min.css'); require('admin-lte/dist/css/AdminLTE.min.css');
@@ -69,6 +73,7 @@ require('./sass/app.scss');
// ------ Kimai itself ------ // ------ Kimai itself ------
require('./js/KimaiWebLoader.js'); require('./js/KimaiWebLoader.js');
global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default;
// ------ Autocomplete for tags only ------ // ------ Autocomplete for tags only ------
require('jquery-ui/ui/widgets/autocomplete'); require('jquery-ui/ui/widgets/autocomplete');

View File

@@ -61,6 +61,8 @@ export default class KimaiDateRangePicker extends KimaiPlugin {
jQuery(this).on('apply.daterangepicker', function(ev, picker) { jQuery(this).on('apply.daterangepicker', function(ev, picker) {
jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat)); jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
jQuery(this).data('begin', picker.startDate.format(localeFormat));
jQuery(this).data('end', picker.endDate.format(localeFormat));
jQuery(this).trigger("change"); jQuery(this).trigger("change");
}); });
}); });

View File

@@ -11,6 +11,7 @@
import jQuery from 'jquery'; import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin"; import KimaiPlugin from "../KimaiPlugin";
import moment from 'moment';
export default class KimaiSelectDataAPI extends KimaiPlugin { export default class KimaiSelectDataAPI extends KimaiPlugin {
@@ -32,30 +33,78 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
const API = this.getContainer().getPlugin('api'); const API = this.getContainer().getPlugin('api');
jQuery('body').on('change', selector, function(event) { jQuery('body').on('change', selector, function(event) {
let apiUrl = jQuery(this).attr('data-api-url').replace('-s-', jQuery(this).val()); const targetSelect = '#' + this.dataset['relatedSelect'];
const targetSelect = '#' + jQuery(this).attr('data-related-select');
// if the related target select does not exist, we do not need to load the related data // if the related target select does not exist, we do not need to load the related data
if (jQuery(targetSelect).length === 0) { if (jQuery(targetSelect).length === 0) {
return; return;
} }
let formPrefix = jQuery(this).parents('form').first().attr('name');
if (formPrefix === undefined || formPrefix === null) {
formPrefix = '';
} else {
formPrefix += '_';
}
let newApiUrl = self._buildUrlWithFormFields(this.dataset['apiUrl'], formPrefix);
if (jQuery(this).val() === '') { if (jQuery(this).val() === '') {
if (jQuery(this).attr('data-empty-url') === undefined) { if (this.dataset['emptyUrl'] === undefined) {
self._updateSelect(targetSelect, {}); self._updateSelect(targetSelect, {});
jQuery(targetSelect).attr('disabled', 'disabled'); jQuery(targetSelect).attr('disabled', 'disabled');
return; return;
} }
apiUrl = jQuery(this).attr('data-empty-url').replace('-s-', jQuery(this).val()); newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
} }
jQuery(targetSelect).removeAttr('disabled'); jQuery(targetSelect).removeAttr('disabled');
API.get(apiUrl, {}, function(data){ API.get(newApiUrl, {}, function(data){
self._updateSelect(targetSelect, data); self._updateSelect(targetSelect, data);
}); });
}); });
} }
_buildUrlWithFormFields(apiUrl, formPrefix) {
let newApiUrl = apiUrl;
apiUrl.split('?')[1].split('&').forEach(item => {
let [key, value] = item.split('=');
let decoded = decodeURIComponent(value);
let test = decoded.match(/%(.*)%/);
if (test !== null) {
let targetField = jQuery('#' + formPrefix + test[1]);
let newValue = '';
if (targetField.length === 0) {
// debug: this case for example happens in duration only mode, when the end field is not found
//console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
} else {
if (targetField.val() !== null) {
newValue = targetField.val();
if (newValue !== '') {
// having that special case here is far from being perfect... but for now it works ;-)
if (targetField.data('daterangepicker') !== undefined) {
if (key === 'begin' || key === 'start' || targetField.data('daterangepicker').singleDatePicker) {
newValue = targetField.data('daterangepicker').startDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
} else if (key === 'end') {
newValue = targetField.data('daterangepicker').endDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
}
} else if (targetField.data('format') !== undefined) {
if (moment(newValue, targetField.data('format')).isValid()) {
newValue = moment(newValue, targetField.data('format')).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
}
}
}
}
}
newApiUrl = newApiUrl.replace(value, newValue);
}
});
return newApiUrl;
}
_updateSelect(selectName, data) { _updateSelect(selectName, data) {
const options = {}; const options = {};

View File

@@ -0,0 +1,92 @@
/*
* 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] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
*/
import jQuery from "jquery";
export default class KimaiPaginatedBoxWidget {
constructor(boxId) {
this.selector = boxId;
this.overlay = jQuery('<div class="overlay"><div class="fa fa-refresh fa-spin"></div></div>');
this.widget = jQuery(this.selector);
this.href = this.widget.data('href');
this.events = this.widget.data('reload').split(' ');
const self = this;
const reloadPage = function (event) {
const page = jQuery(self.selector + ' .box-tools').data('page');
const url = self._buildUrl(page);
self.loadPage(url);
};
for (const eventName of this.events) {
document.addEventListener(eventName, reloadPage);
}
this.widget.on('click', '.box-tools a', function (event) {
event.preventDefault();
self.loadPage(jQuery(event.target).attr('href'));
});
}
static create(elementId) {
return new KimaiPaginatedBoxWidget(elementId);
}
_showOverlay() {
this.widget.append(this.overlay);
}
_hideOverlay() {
jQuery(this.overlay).remove();
}
loadPage(url) {
const self = this;
const selector = this.selector;
self._showOverlay();
jQuery.ajax({
url: url,
data: {},
success: function (response) {
const html = jQuery(response);
jQuery(selector + ' .box-tools').replaceWith(html.find('.box-tools'));
jQuery(selector + ' .box-body').replaceWith(html.find('.box-body'));
self.widget.removeData('error-retry');
self._hideOverlay();
},
dataType: 'html',
error: function(jqXHR, textStatus, errorThrown) {
if (jqXHR.status !== undefined && jqXHR.status === 500) {
if (self.widget.data('error-retry') !== undefined) {
// TODO show error message ?
return;
}
const page = jQuery(selector + ' .box-tools').data('page');
if (page > 1) {
self.widget.data('error-retry', 1);
var url = self._buildUrl(page - 1);
self.loadPage(url);
}
}
self._hideOverlay();
}
});
}
_buildUrl(page) {
return this.href.replace('1', page);
}
}

View File

@@ -23,7 +23,7 @@ section.content .overlay > .fas,
section.content > .overlay, section.content > .overlay,
.box > .loading-img, .box > .loading-img,
.overlay-wrapper > .loading-img { .overlay-wrapper > .loading-img {
position: absolute; position: fixed; /* see #1330 */
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;

View File

@@ -39,3 +39,13 @@
} }
} }
} }
/*
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;
}
*/

View File

@@ -73,9 +73,7 @@
"symfony/css-selector": "^4.0", "symfony/css-selector": "^4.0",
"symfony/maker-bundle": "^1.12", "symfony/maker-bundle": "^1.12",
"symfony/phpunit-bridge": "^4.0", "symfony/phpunit-bridge": "^4.0",
"symfony/profiler-pack": "^1.0", "symfony/profiler-pack": "^1.0"
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0"
}, },
"repositories": [ "repositories": [
{ {

1123
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,6 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\WebServerBundle\WebServerBundle::class => ['dev' => true],
Doctrine\Bundle\DoctrineCacheBundle\DoctrineCacheBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],

View File

@@ -1,6 +1,6 @@
App\Entity\Activity: App\Entity\Activity:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields] custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle]
properties: properties:
id: id:
include: true include: true

View File

@@ -1,6 +1,6 @@
App\Entity\Project: App\Entity\Project:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, fixedRate, hourlyRate, color, budget, timeBudget, metaFields] custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle]
properties: properties:
id: id:
include: true include: true
@@ -23,6 +23,12 @@ App\Entity\Project:
orderDate: orderDate:
include: true include: true
groups: [Entity] groups: [Entity]
start:
include: true
groups: [Project]
end:
include: true
groups: [Project]
fixedRate: fixedRate:
include: true include: true
groups: [Project] groups: [Project]

View File

@@ -39,6 +39,7 @@
"sass-loader": "^7.0.2", "sass-loader": "^7.0.2",
"select2": "^4.0.10", "select2": "^4.0.10",
"select2-bootstrap-theme": "^0.1.0-beta.10", "select2-bootstrap-theme": "^0.1.0-beta.10",
"sortablejs": "^1.10.1",
"sweetalert2": "^9.3", "sweetalert2": "^9.3",
"webpack-notifier": "^1.5.1" "webpack-notifier": "^1.5.1"
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
"build/runtime.6ca1eea5.js", "build/runtime.6ca1eea5.js",
"build/0.1a6ffb43.js", "build/0.1a6ffb43.js",
"build/1.16767f92.js", "build/1.16767f92.js",
"build/app.23b99cbb.js" "build/app.055e71d1.js"
], ],
"css": [ "css": [
"build/app.ce77694e.css" "build/app.483d7949.css"
] ]
}, },
"chart": { "chart": {
@@ -35,8 +35,8 @@
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD", "build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG", "build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.23b99cbb.js": "sha384-OcCvUr52EqOwqv3QvQVwP6y4AOv3JRpxwp629Df5QnPAkZqHzX5NwbaZJQZ0Xzfl", "build/app.055e71d1.js": "sha384-NGXBt+8vQ5h4orQ5B82W9GoeVpu4bNmiJI7k/goKm1ogp2JGvo0tyadJdoOf3Dt+",
"build/app.ce77694e.css": "sha384-xUdH11jkeXQ1YfJ66YW+tlcQt23/PKuPZmMi4XJuVuMLdonUk0qKAJ0HJrRL5hvf", "build/app.483d7949.css": "sha384-u8/TZyq87MTQTKScxUGmbJcWEBhsoXAlsUTB8XSgKvuufZ5bODWYm1o1E1DRosJu",
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL", "build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1", "build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1",

View File

@@ -2,8 +2,8 @@
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js", "build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
"build/1.16767f92.js": "build/1.16767f92.js", "build/1.16767f92.js": "build/1.16767f92.js",
"build/2.dad56560.js": "build/2.dad56560.js", "build/2.dad56560.js": "build/2.dad56560.js",
"build/app.css": "build/app.ce77694e.css", "build/app.css": "build/app.483d7949.css",
"build/app.js": "build/app.23b99cbb.js", "build/app.js": "build/app.055e71d1.js",
"build/calendar.css": "build/calendar.ade7bcdf.css", "build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.9e7cdb6a.js", "build/calendar.js": "build/calendar.9e7cdb6a.js",
"build/chart.js": "build/chart.7f4d7f29.js", "build/chart.js": "build/chart.7f4d7f29.js",

View File

@@ -16,6 +16,7 @@ use App\Event\ProjectMetaDefinitionEvent;
use App\Form\API\ProjectApiEditForm; use App\Form\API\ProjectApiEditForm;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\SearchTerm; use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest; use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource; use FOS\RestBundle\Controller\Annotations\RouteResource;
@@ -29,6 +30,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Validator\Constraints;
/** /**
* @RouteResource("Project") * @RouteResource("Project")
@@ -49,16 +51,21 @@ class ProjectController extends BaseApiController
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
private $dispatcher; private $dispatcher;
/**
* @var UserDateTimeFactory
*/
private $dateTime;
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher) public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository, EventDispatcherInterface $dispatcher, UserDateTimeFactory $dateTime)
{ {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->dateTime = $dateTime;
} }
/** /**
* Returns a collection of projects * Returns a collection of projects.
* *
* @SWG\Response( * @SWG\Response(
* response=200, * response=200,
@@ -69,7 +76,10 @@ class ProjectController extends BaseApiController
* ) * )
* ) * )
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects") * @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter projects")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects (1=visible, 2=hidden, 3=both)") * @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter projects. Allowed values: 1=visible, 2=hidden, 3=both (default; 1)")
* @Rest\QueryParam(name="start", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that started before this date will be included. Allowed format: HTML5 (default: now, if end is also empty)")
* @Rest\QueryParam(name="end", requirements=@Constraints\DateTime(format="Y-m-d\TH:i:s"), strict=true, nullable=true, description="Only projects that ended after this date will be included. Allowed format: HTML5 (default: now, if start is also empty)")
* @Rest\QueryParam(name="ignoreDates", requirements="1", strict=true, nullable=true, description="If set, start and end are completely ignored. Allowed values: 1 (default: off)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)") * @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order. Allowed values: ASC, DESC (default: ASC)")
* @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)") * @Rest\QueryParam(name="orderBy", requirements="id|name|customer", strict=true, nullable=true, description="The field by which results will be ordered. Allowed values: id, name, customer (default: name)")
* @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term") * @Rest\QueryParam(name="term", requirements="[a-zA-Z0-9 \-,:]+", strict=true, nullable=true, description="Free search term")
@@ -98,6 +108,27 @@ class ProjectController extends BaseApiController
$query->setVisibility($visible); $query->setVisibility($visible);
} }
$ignoreDates = false;
if (null !== $paramFetcher->get('ignoreDates')) {
$ignoreDates = intval($paramFetcher->get('ignoreDates')) === 1;
}
if (!$ignoreDates) {
if (null !== ($begin = $paramFetcher->get('start')) && !empty($begin)) {
$query->setProjectStart($this->dateTime->createDateTime($begin));
}
if (null !== ($end = $paramFetcher->get('end')) && !empty($end)) {
$query->setProjectEnd($this->dateTime->createDateTime($end));
}
if (empty($begin) && empty($end)) {
$now = $this->dateTime->createDateTime();
$query->setProjectStart($now);
$query->setProjectEnd($now);
}
}
if (!empty($term = $paramFetcher->get('term'))) { if (!empty($term = $paramFetcher->get('term'))) {
$query->setSearchTerm(new SearchTerm($term)); $query->setSearchTerm(new SearchTerm($term));
} }

View File

@@ -20,6 +20,7 @@ use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\RoundingService; use App\Timesheet\RoundingService;
use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService; use App\Timesheet\TrackingModeService;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
@@ -81,6 +82,10 @@ class TimesheetController extends BaseApiController
* @var RoundingService * @var RoundingService
*/ */
private $roundingService; private $roundingService;
/**
* @var TimesheetService
*/
private $service;
public function __construct( public function __construct(
ViewHandlerInterface $viewHandler, ViewHandlerInterface $viewHandler,
@@ -90,7 +95,8 @@ class TimesheetController extends BaseApiController
TagRepository $tagRepository, TagRepository $tagRepository,
TrackingModeService $trackingModeService, TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher, EventDispatcherInterface $dispatcher,
RoundingService $roundingService RoundingService $roundingService,
TimesheetService $service
) { ) {
$this->viewHandler = $viewHandler; $this->viewHandler = $viewHandler;
$this->repository = $repository; $this->repository = $repository;
@@ -100,6 +106,7 @@ class TimesheetController extends BaseApiController
$this->trackingModeService = $trackingModeService; $this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->roundingService = $roundingService; $this->roundingService = $roundingService;
$this->service = $service;
} }
protected function getTrackingMode(): TrackingModeInterface protected function getTrackingMode(): TrackingModeInterface
@@ -295,14 +302,9 @@ class TimesheetController extends BaseApiController
*/ */
public function postAction(Request $request): Response public function postAction(Request $request): Response
{ {
$timesheet = new Timesheet(); $timesheet = $this->service->createNewTimesheet($this->getUser(), $request);
$timesheet->setUser($this->getUser());
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event);
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$mode->create($timesheet, $request);
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [ $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet), 'include_rate' => $this->isGranted('edit_rate', $timesheet),
@@ -316,18 +318,16 @@ class TimesheetController extends BaseApiController
$form->submit($request->request->all(), false); $form->submit($request->request->all(), false);
if ($form->isValid()) { if ($form->isValid()) {
if (null === $timesheet->getEnd()) { try {
if (!$this->isGranted('start', $timesheet)) { $this->service->saveNewTimesheet($timesheet);
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record'); } catch (\Exception $ex) {
if ($ex->getMessage() === 'timesheet.start.exceeded_limit') {
throw new BadRequestHttpException('Too many active timesheets');
} else {
throw $ex;
} }
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->configuration->getActiveEntriesHardLimit()
);
} }
$this->repository->save($timesheet);
$view = new View($timesheet, 200); $view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
@@ -559,7 +559,7 @@ class TimesheetController extends BaseApiController
throw new AccessDeniedHttpException('You are not allowed to stop this timesheet'); throw new AccessDeniedHttpException('You are not allowed to stop this timesheet');
} }
$this->repository->stopRecording($timesheet); $this->service->stopTimesheet($timesheet);
$view = new View($timesheet, 200); $view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
@@ -600,13 +600,10 @@ class TimesheetController extends BaseApiController
throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet'); throw new AccessDeniedHttpException('You are not allowed to re-start this timesheet');
} }
/** @var User $user */ $copyTimesheet = $this->service->createNewTimesheet($this->getUser());
$user = $this->getUser();
$copyTimesheet = new Timesheet();
$copyTimesheet $copyTimesheet
->setBegin($this->dateTime->createDateTime()) ->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity()) ->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject()) ->setProject($timesheet->getProject())
; ;
@@ -642,12 +639,7 @@ class TimesheetController extends BaseApiController
throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()); throw new BadRequestHttpException($errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage());
} }
$this->repository->stopActiveEntries( $this->service->saveNewTimesheet($copyTimesheet);
$user,
$this->configuration->getActiveEntriesHardLimit()
);
$this->repository->save($copyTimesheet);
$view = new View($copyTimesheet, 200); $view = new View($copyTimesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']); $view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);

View File

@@ -10,7 +10,7 @@
namespace App\Command; namespace App\Command;
use App\Entity\User; use App\Entity\User;
use Symfony\Bridge\Doctrine\RegistryInterface; use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
@@ -21,34 +21,23 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
/** final class CreateUserCommand extends Command
* Command used to create application user.
*/
class CreateUserCommand extends Command
{ {
/** /**
* @var UserPasswordEncoderInterface * @var UserPasswordEncoderInterface
*/ */
protected $encoder; private $encoder;
/** /**
* @var RegistryInterface * @var ManagerRegistry
*/ */
protected $doctrine; private $doctrine;
/** /**
* @var ValidatorInterface * @var ValidatorInterface
*/ */
protected $validator; private $validator;
/** public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
* @param UserPasswordEncoderInterface $encoder {
* @param RegistryInterface $registry
* @param ValidatorInterface $validator
*/
public function __construct(
UserPasswordEncoderInterface $encoder,
RegistryInterface $registry,
ValidatorInterface $validator
) {
$this->encoder = $encoder; $this->encoder = $encoder;
$this->doctrine = $registry; $this->doctrine = $registry;
$this->validator = $validator; $this->validator = $validator;
@@ -130,14 +119,14 @@ class CreateUserCommand extends Command
$entityManager->persist($user); $entityManager->persist($user);
$entityManager->flush(); $entityManager->flush();
$io->success('Success! Created user: ' . $user->getUsername()); $io->success('Success! Created user: ' . $user->getUsername());
return 0;
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername()); $io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
return 2;
} }
return 2; return 0;
} }
/** /**

View File

@@ -448,6 +448,7 @@ class ImportTimesheetCommand extends Command
if (is_int($customer)) { if (is_int($customer)) {
$tmpFallback = $this->customers->find($fallback); $tmpFallback = $this->customers->find($fallback);
} else { } else {
/** @var Customer|null $tmpFallback */
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]); $tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
} }
} }

View File

@@ -11,25 +11,34 @@ namespace App\Command;
use App\Doctrine\TimesheetSubscriber; use App\Doctrine\TimesheetSubscriber;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Timesheet\Util; use App\Timesheet\Util;
use Doctrine\Common\Persistence\ObjectManager; use DateTime;
use DateTimeZone;
use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager; use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\DateTimeType; use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Type;
use Symfony\Bridge\Doctrine\RegistryInterface; use Doctrine\DBAL\Types\Types;
use Doctrine\Persistence\ManagerRegistry;
use Doctrine\Persistence\ObjectManager;
use Exception;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Validator\Validator\ValidatorInterface;
/** /**
@@ -39,7 +48,7 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
* This command is way to messy and complex to be tested ... so we use something, which I actually don't like: * This command is way to messy and complex to be tested ... so we use something, which I actually don't like:
* @codeCoverageIgnore * @codeCoverageIgnore
*/ */
class KimaiImporterCommand extends Command final class KimaiImporterCommand extends Command
{ {
// minimum required Kimai and database version, lower versions are not supported by this command // minimum required Kimai and database version, lower versions are not supported by this command
public const MIN_VERSION = '1.0.1'; public const MIN_VERSION = '1.0.1';
@@ -57,7 +66,7 @@ class KimaiImporterCommand extends Command
protected $validator; protected $validator;
/** /**
* Connection to the Kimai v2 database to write imported data to * Connection to the Kimai v2 database to write imported data to
* @var RegistryInterface * @var ManagerRegistry
*/ */
protected $doctrine; protected $doctrine;
/** /**
@@ -96,16 +105,8 @@ class KimaiImporterCommand extends Command
*/ */
protected $oldActivities = []; protected $oldActivities = [];
/** public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
* @param UserPasswordEncoderInterface $encoder {
* @param RegistryInterface $registry
* @param ValidatorInterface $validator
*/
public function __construct(
UserPasswordEncoderInterface $encoder,
RegistryInterface $registry,
ValidatorInterface $validator
) {
$this->encoder = $encoder; $this->encoder = $encoder;
$this->doctrine = $registry; $this->doctrine = $registry;
$this->validator = $validator; $this->validator = $validator;
@@ -125,12 +126,14 @@ class KimaiImporterCommand extends Command
->addArgument( ->addArgument(
'connection', 'connection',
InputArgument::REQUIRED, InputArgument::REQUIRED,
'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=latin1' 'The database connection as URL, e.g.: mysql://user:password@127.0.0.1:3306/kimai?charset=utf8'
) )
->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables') ->addArgument('prefix', InputArgument::REQUIRED, 'The database prefix for the old Kimai v1 tables')
->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user') ->addArgument('password', InputArgument::REQUIRED, 'The new password for all imported user')
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE') ->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE')
->addArgument('currency', InputArgument::OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR') ->addArgument('currency', InputArgument::OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR')
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Default timezone for imported users', date_default_timezone_get())
->addOption('language', null, InputOption::VALUE_OPTIONAL, 'Default language for imported users', User::DEFAULT_LANGUAGE)
; ;
} }
@@ -140,7 +143,7 @@ class KimaiImporterCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output) protected function execute(InputInterface $input, OutputInterface $output)
{ {
// do not convert the times, Kimai 1 stored them already in UTC // do not convert the times, Kimai 1 stored them already in UTC
Type::overrideType(Type::DATETIME, DateTimeType::class); Type::overrideType(Types::DATETIME_MUTABLE, DateTimeType::class);
// don't calculate rates ... this was done in Kimai 1 // don't calculate rates ... this was done in Kimai 1
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection()); $this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
@@ -157,25 +160,25 @@ class KimaiImporterCommand extends Command
if (trim(strlen($password)) < 6) { if (trim(strlen($password)) < 6) {
$io->error('Password length is not sufficient, at least 6 character are required'); $io->error('Password length is not sufficient, at least 6 character are required');
return; return 1;
} }
$country = $input->getArgument('country'); $country = $input->getArgument('country');
if (2 != trim(strlen($country))) { if (2 != trim(strlen($country))) {
$io->error('Country code needs to be exactly 2 character'); $io->error('Country code needs to be exactly 2 character');
return; return 1;
} }
$currency = $input->getArgument('currency'); $currency = $input->getArgument('currency');
if (3 != trim(strlen($currency))) { if (3 != trim(strlen($currency))) {
$io->error('Currency code needs to be exactly 3 character'); $io->error('Currency code needs to be exactly 3 character');
return; return 1;
} }
if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) { if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) {
return; return 1;
} }
$bytesStart = memory_get_usage(true); $bytesStart = memory_get_usage(true);
@@ -183,66 +186,66 @@ class KimaiImporterCommand extends Command
// pre-load all data to make sure we can fully import everything // pre-load all data to make sure we can fully import everything
try { try {
$users = $this->fetchAllFromImport('users'); $users = $this->fetchAllFromImport('users');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load users: ' . $ex->getMessage()); $io->error('Failed to load users: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$customer = $this->fetchAllFromImport('customers'); $customer = $this->fetchAllFromImport('customers');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load customers: ' . $ex->getMessage()); $io->error('Failed to load customers: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$projects = $this->fetchAllFromImport('projects'); $projects = $this->fetchAllFromImport('projects');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load projects: ' . $ex->getMessage()); $io->error('Failed to load projects: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$activities = $this->fetchAllFromImport('activities'); $activities = $this->fetchAllFromImport('activities');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load activities: ' . $ex->getMessage()); $io->error('Failed to load activities: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$activityToProject = $this->fetchAllFromImport('projects_activities'); $activityToProject = $this->fetchAllFromImport('projects_activities');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load activities-project mapping: ' . $ex->getMessage()); $io->error('Failed to load activities-project mapping: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$records = $this->fetchAllFromImport('timeSheet'); $records = $this->fetchAllFromImport('timeSheet');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load timeSheet: ' . $ex->getMessage()); $io->error('Failed to load timeSheet: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$fixedRates = $this->fetchAllFromImport('fixedRates'); $fixedRates = $this->fetchAllFromImport('fixedRates');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load fixedRates: ' . $ex->getMessage()); $io->error('Failed to load fixedRates: ' . $ex->getMessage());
return; return 1;
} }
try { try {
$rates = $this->fetchAllFromImport('rates'); $rates = $this->fetchAllFromImport('rates');
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to load rates: ' . $ex->getMessage()); $io->error('Failed to load rates: ' . $ex->getMessage());
return; return 1;
} }
$bytesCached = memory_get_usage(true); $bytesCached = memory_get_usage(true);
@@ -252,57 +255,55 @@ class KimaiImporterCommand extends Command
$allImports = 0; $allImports = 0;
try { try {
$counter = $this->importUsers($io, $password, $users, $rates); $counter = $this->importUsers($io, $password, $users, $rates, $input->getOption('timezone'), $input->getOption('language'));
$allImports += $counter; $allImports += $counter;
$io->success('Imported users: ' . $counter); $io->success('Imported users: ' . $counter);
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); $io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return; return 1;
} }
try { try {
$counter = $this->importCustomers($io, $customer, $country, $currency); $counter = $this->importCustomers($io, $customer, $country, $currency);
$allImports += $counter; $allImports += $counter;
$io->success('Imported customers: ' . $counter); $io->success('Imported customers: ' . $counter);
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); $io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return; return 1;
} }
try { try {
$counter = $this->importProjects($io, $projects, $fixedRates, $rates); $counter = $this->importProjects($io, $projects, $fixedRates, $rates);
$allImports += $counter; $allImports += $counter;
$io->success('Imported projects: ' . $counter); $io->success('Imported projects: ' . $counter);
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); $io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return; return 1;
} }
try { try {
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates); $counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
$allImports += $counter; $allImports += $counter;
$io->success('Imported activities: ' . $counter); $io->success('Imported activities: ' . $counter);
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); $io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return; return 1;
} }
try { try {
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates); $counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
$allImports += $counter; $allImports += $counter;
$io->success('Imported timesheet records: ' . $counter); $io->success('Imported timesheet records: ' . $counter);
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString()); $io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return; return 1;
} }
// TODO support expenses - new database required
$bytesImported = memory_get_usage(true); $bytesImported = memory_get_usage(true);
$io->success( $io->success(
@@ -313,6 +314,8 @@ class KimaiImporterCommand extends Command
'Total consumption for importing ' . $allImports . ' new database entries: ' . 'Total consumption for importing ' . $allImports . ' new database entries: ' .
$this->bytesHumanReadable($bytesImported - $bytesStart) $this->bytesHumanReadable($bytesImported - $bytesStart)
); );
return 0;
} }
/** /**
@@ -323,7 +326,6 @@ class KimaiImporterCommand extends Command
* @param string $requiredVersion * @param string $requiredVersion
* @param string $requiredRevision * @param string $requiredRevision
* @return bool * @return bool
* @throws \Doctrine\DBAL\DBALException
*/ */
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision) protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{ {
@@ -419,7 +421,7 @@ class KimaiImporterCommand extends Command
} }
/** /**
* @return RegistryInterface * @return ManagerRegistry
*/ */
protected function getDoctrine() protected function getDoctrine()
{ {
@@ -436,7 +438,7 @@ class KimaiImporterCommand extends Command
$errors = $this->validator->validate($object); $errors = $this->validator->validate($object);
if ($errors->count() > 0) { if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */ /** @var ConstraintViolation $error */
foreach ($errors as $error) { foreach ($errors as $error) {
$io->error( $io->error(
(string) $error (string) $error
@@ -476,10 +478,12 @@ class KimaiImporterCommand extends Command
* @param string $password * @param string $password
* @param array $users * @param array $users
* @param array $rates * @param array $rates
* @param string $timezone
* @param string $language
* @return int * @return int
* @throws \Exception * @throws Exception
*/ */
protected function importUsers(SymfonyStyle $io, $password, $users, $rates) protected function importUsers(SymfonyStyle $io, $password, $users, $rates, $timezone, $language)
{ {
$counter = 0; $counter = 0;
$entityManager = $this->getDoctrine()->getManager(); $entityManager = $this->getDoctrine()->getManager();
@@ -501,7 +505,7 @@ class KimaiImporterCommand extends Command
$user->setPassword($pwd); $user->setPassword($pwd);
if (!$this->validateImport($io, $user)) { if (!$this->validateImport($io, $user)) {
throw new \Exception('Failed to validate user: ' . $user->getUsername()); throw new Exception('Failed to validate user: ' . $user->getUsername());
} }
// find and migrate user preferences // find and migrate user preferences
@@ -525,6 +529,14 @@ class KimaiImporterCommand extends Command
$user->addPreference($newPref); $user->addPreference($newPref);
} }
// set default values if they were not set in the the user preferences
$defaults = ['language' => $language, 'timezone' => $timezone];
foreach ($defaults as $key => $default) {
if (null === $user->getPreferenceValue($key)) {
$user->setPreferenceValue($key, $default);
}
}
// find hourly rate // find hourly rate
foreach ($rates as $ratesRow) { foreach ($rates as $ratesRow) {
if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) { if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) {
@@ -543,7 +555,7 @@ class KimaiImporterCommand extends Command
$io->success('Created user: ' . $user->getUsername()); $io->success('Created user: ' . $user->getUsername());
} }
++$counter; ++$counter;
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername()); $io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
} }
@@ -584,7 +596,7 @@ class KimaiImporterCommand extends Command
* @param string $country * @param string $country
* @param string $currency * @param string $currency
* @return int * @return int
* @throws \Exception * @throws Exception
*/ */
protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency) protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency)
{ {
@@ -617,8 +629,15 @@ class KimaiImporterCommand extends Command
->setCurrency(strtoupper($currency)) ->setCurrency(strtoupper($currency))
; ;
$metaField = new CustomerMeta();
$metaField->setName('_imported_id');
$metaField->setValue($oldCustomer['customerID']);
$metaField->setIsVisible(false);
$customer->setMetaField($metaField);
if (!$this->validateImport($io, $customer)) { if (!$this->validateImport($io, $customer)) {
throw new \Exception('Failed to validate customer: ' . $customer->getName()); throw new Exception('Failed to validate customer: ' . $customer->getName());
} }
try { try {
@@ -628,7 +647,7 @@ class KimaiImporterCommand extends Command
$io->success('Created customer: ' . $customer->getName()); $io->success('Created customer: ' . $customer->getName());
} }
++$counter; ++$counter;
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
$io->error('Failed to create customer: ' . $customer->getName()); $io->error('Failed to create customer: ' . $customer->getName());
} }
@@ -659,7 +678,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates * @param array $fixedRates
* @param array $rates * @param array $rates
* @return int * @return int
* @throws \Exception * @throws Exception
*/ */
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates) protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
{ {
@@ -684,6 +703,13 @@ class KimaiImporterCommand extends Command
->setBudget($oldProject['budget'] ?: 0) ->setBudget($oldProject['budget'] ?: 0)
; ;
$metaField = new ProjectMeta();
$metaField->setName('_imported_id');
$metaField->setValue($oldProject['projectID']);
$metaField->setIsVisible(false);
$project->setMetaField($metaField);
foreach ($fixedRates as $fixedRow) { foreach ($fixedRates as $fixedRow) {
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) { if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
continue; continue;
@@ -703,7 +729,7 @@ class KimaiImporterCommand extends Command
} }
if (!$this->validateImport($io, $project)) { if (!$this->validateImport($io, $project)) {
throw new \Exception('Failed to validate project: ' . $project->getName()); throw new Exception('Failed to validate project: ' . $project->getName());
} }
try { try {
@@ -713,7 +739,7 @@ class KimaiImporterCommand extends Command
$io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName()); $io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName());
} }
++$counter; ++$counter;
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to create project: ' . $project->getName()); $io->error('Failed to create project: ' . $project->getName());
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
} }
@@ -748,7 +774,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates * @param array $fixedRates
* @param array $rates * @param array $rates
* @return int * @return int
* @throws \Exception * @throws Exception
*/ */
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates) protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates)
{ {
@@ -781,7 +807,7 @@ class KimaiImporterCommand extends Command
} }
foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) { foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) {
if (!isset($this->projects[$projectId])) { if (!isset($this->projects[$projectId])) {
throw new \Exception( throw new Exception(
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId 'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
); );
} }
@@ -802,7 +828,7 @@ class KimaiImporterCommand extends Command
* @param array $rates * @param array $rates
* @param int $projectId * @param int $projectId
* @return Activity * @return Activity
* @throws \Exception * @throws Exception
*/ */
protected function createActivity( protected function createActivity(
SymfonyStyle $io, SymfonyStyle $io,
@@ -826,7 +852,7 @@ class KimaiImporterCommand extends Command
} }
if (null !== $projectId && !isset($this->projects[$projectId])) { if (null !== $projectId && !isset($this->projects[$projectId])) {
throw new \Exception( throw new Exception(
sprintf('Did not find project [%s], skipping activity creation [%s] %s', $projectId, $activityId, $name) sprintf('Did not find project [%s], skipping activity creation [%s] %s', $projectId, $activityId, $name)
); );
} }
@@ -844,6 +870,13 @@ class KimaiImporterCommand extends Command
$activity->setProject($project); $activity->setProject($project);
} }
$metaField = new ActivityMeta();
$metaField->setName('_imported_id');
$metaField->setValue($oldActivity['activityID']);
$metaField->setIsVisible(false);
$activity->setMetaField($metaField);
foreach ($fixedRates as $fixedRow) { foreach ($fixedRates as $fixedRow) {
if ($fixedRow['activityID'] === null) { if ($fixedRow['activityID'] === null) {
continue; continue;
@@ -871,7 +904,7 @@ class KimaiImporterCommand extends Command
} }
if (!$this->validateImport($io, $activity)) { if (!$this->validateImport($io, $activity)) {
throw new \Exception('Failed to validate activity: ' . $activity->getName()); throw new Exception('Failed to validate activity: ' . $activity->getName());
} }
try { try {
@@ -880,7 +913,7 @@ class KimaiImporterCommand extends Command
if ($this->debug) { if ($this->debug) {
$io->success('Created activity: ' . $activity->getName()); $io->success('Created activity: ' . $activity->getName());
} }
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to create activity: ' . $activity->getName()); $io->error('Failed to create activity: ' . $activity->getName());
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
} }
@@ -921,7 +954,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates * @param array $fixedRates
* @param array $rates * @param array $rates
* @return int * @return int
* @throws \Exception * @throws Exception
*/ */
protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates) protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates)
{ {
@@ -1010,7 +1043,7 @@ class KimaiImporterCommand extends Command
$io->success('Created deactivated user: ' . $user->getUsername()); $io->success('Created deactivated user: ' . $user->getUsername());
} }
$userCounter++; $userCounter++;
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername()); $io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage()); $io->error('Reason: ' . $ex->getMessage());
$failed++; $failed++;
@@ -1043,11 +1076,11 @@ class KimaiImporterCommand extends Command
$user = $this->users[$oldRecord['userID']]; $user = $this->users[$oldRecord['userID']];
$timezone = $user->getTimezone(); $timezone = $user->getTimezone();
$dateTimezone = new \DateTimeZone('UTC'); $dateTimezone = new DateTimeZone('UTC');
$begin = new \DateTime('@' . $oldRecord['start']); $begin = new DateTime('@' . $oldRecord['start']);
$begin->setTimezone($dateTimezone); $begin->setTimezone($dateTimezone);
$end = new \DateTime('@' . $oldRecord['end']); $end = new DateTime('@' . $oldRecord['end']);
$end->setTimezone($dateTimezone); $end->setTimezone($dateTimezone);
// ---------- workaround for localizeDates ---------- // ---------- workaround for localizeDates ----------
@@ -1075,30 +1108,33 @@ class KimaiImporterCommand extends Command
; ;
if (!$this->validateImport($io, $timesheet)) { if (!$this->validateImport($io, $timesheet)) {
$io->error('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!'); $io->caution('Failed to validate timesheet record: ' . $oldRecord['timeEntryID'] . ' - skipping!');
$failed++; $failed++;
continue; continue;
} }
try { try {
$entityManager->persist($timesheet); $entityManager->persist($timesheet);
$entityManager->flush();
if ($this->debug) { if ($this->debug) {
$io->success('Created timesheet record: ' . $timesheet->getId()); $io->success('Created timesheet record: ' . $timesheet->getId());
} }
++$counter; ++$counter;
} catch (\Exception $ex) { } catch (Exception $ex) {
$io->error('Failed to create timesheet record: ' . $ex->getMessage()); $io->error('Failed to create timesheet record: ' . $ex->getMessage());
$failed++; $failed++;
} }
$io->write('.'); $io->write('.');
if (0 == $counter % 80) { if (0 == $counter % 80) {
$io->writeln(' (' . $counter . '/' . $total . ')'); $entityManager->flush();
$entityManager->clear(Timesheet::class); $entityManager->clear(Timesheet::class);
$io->writeln(' (' . $counter . '/' . $total . ')');
} }
} }
$entityManager->flush();
$entityManager->clear(Timesheet::class);
for ($i = 0; $i < 80 - ($counter % 80); $i++) { for ($i = 0; $i < 80 - ($counter % 80); $i++) {
$io->write(' '); $io->write(' ');
} }

View File

@@ -68,7 +68,7 @@ class VersionCommand extends Command
return 0; return 0;
} }
$io->writeln('Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.'); $io->writeln(Constants::SOFTWARE . ' - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
return 0; return 0;
} }

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '1.6.2'; public const VERSION = '1.7';
/** /**
* The current release status, either "stable" or "dev" * The current release status, either "stable" or "dev"
*/ */
public const STATUS = 'stable'; public const STATUS = 'dev';
/** /**
* The software name * The software name
*/ */

View File

@@ -9,7 +9,6 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\TimesheetConfiguration;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Tag; use App\Entity\Tag;
use App\Entity\Timesheet; use App\Entity\Timesheet;
@@ -27,6 +26,7 @@ use App\Repository\ProjectRepository;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository; use App\Repository\TagRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface; use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Timesheet\TrackingModeService; use App\Timesheet\TrackingModeService;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
@@ -42,10 +42,6 @@ abstract class TimesheetAbstractController extends AbstractController
* @var UserDateTimeFactory * @var UserDateTimeFactory
*/ */
protected $dateTime; protected $dateTime;
/**
* @var TimesheetConfiguration
*/
protected $configuration;
/** /**
* @var TimesheetRepository * @var TimesheetRepository
*/ */
@@ -62,21 +58,25 @@ abstract class TimesheetAbstractController extends AbstractController
* @var ServiceExport * @var ServiceExport
*/ */
protected $exportService; protected $exportService;
/**
* @var TimesheetService
*/
private $service;
public function __construct( public function __construct(
UserDateTimeFactory $dateTime, UserDateTimeFactory $dateTime,
TimesheetConfiguration $configuration,
TimesheetRepository $repository, TimesheetRepository $repository,
TrackingModeService $service, TrackingModeService $trackingModeService,
EventDispatcherInterface $dispatcher, EventDispatcherInterface $dispatcher,
ServiceExport $exportService ServiceExport $exportService,
TimesheetService $timesheetService
) { ) {
$this->dateTime = $dateTime; $this->dateTime = $dateTime;
$this->configuration = $configuration;
$this->repository = $repository; $this->repository = $repository;
$this->trackingModeService = $service; $this->trackingModeService = $trackingModeService;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
$this->exportService = $exportService; $this->exportService = $exportService;
$this->service = $timesheetService;
} }
protected function getTrackingMode(): TrackingModeInterface protected function getTrackingMode(): TrackingModeInterface
@@ -84,16 +84,6 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->trackingModeService->getActiveMode(); return $this->trackingModeService->getActiveMode();
} }
protected function getSoftLimit(): int
{
return $this->configuration->getActiveEntriesSoftLimit();
}
protected function getRepository(): TimesheetRepository
{
return $this->repository;
}
protected function index($page, Request $request, string $renderTemplate, string $location): Response protected function index($page, Request $request, string $renderTemplate, string $location): Response
{ {
$query = new TimesheetQuery(); $query = new TimesheetQuery();
@@ -127,7 +117,7 @@ abstract class TimesheetAbstractController extends AbstractController
$this->prepareQuery($query); $this->prepareQuery($query);
$pager = $this->getRepository()->getPagerfantaForQuery($query); $pager = $this->repository->getPagerfantaForQuery($query);
return $this->render($renderTemplate, [ return $this->render($renderTemplate, [
'entries' => $pager, 'entries' => $pager,
@@ -164,7 +154,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->getRepository()->save($entry); $this->repository->save($entry);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]); return $this->redirectToRoute($this->getTimesheetRoute(), ['page' => $request->get('page', 1)]);
@@ -199,8 +189,7 @@ abstract class TimesheetAbstractController extends AbstractController
protected function create(Request $request, string $renderTemplate, ProjectRepository $projectRepository, ActivityRepository $activityRepository, TagRepository $tagRepository): Response protected function create(Request $request, string $renderTemplate, ProjectRepository $projectRepository, ActivityRepository $activityRepository, TagRepository $tagRepository): Response
{ {
$entry = new Timesheet(); $entry = $this->service->createNewTimesheet($this->getUser());
$entry->setUser($this->getUser());
if ($request->query->get('project')) { if ($request->query->get('project')) {
$project = $projectRepository->find($request->query->get('project')); $project = $projectRepository->find($request->query->get('project'));
@@ -218,24 +207,15 @@ abstract class TimesheetAbstractController extends AbstractController
} }
} }
$event = new TimesheetMetaDefinitionEvent($entry); $this->service->prepareNewTimesheet($entry, $request);
$this->dispatcher->dispatch($event);
$mode = $this->getTrackingMode(); $mode = $this->getTrackingMode();
$mode->create($entry, $request);
$createForm = $this->getCreateForm($entry, $mode); $createForm = $this->getCreateForm($entry, $mode);
$createForm->handleRequest($request); $createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) { if ($createForm->isSubmitted() && $createForm->isValid()) {
try { try {
if (null === $entry->getEnd()) { $this->service->saveNewTimesheet($entry);
$this->getRepository()->stopActiveEntries(
$entry->getUser(),
$this->configuration->getActiveEntriesHardLimit()
);
}
$this->getRepository()->save($entry);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute($this->getTimesheetRoute()); return $this->redirectToRoute($this->getTimesheetRoute());
@@ -273,7 +253,7 @@ abstract class TimesheetAbstractController extends AbstractController
$this->prepareQuery($query); $this->prepareQuery($query);
$entries = $this->getRepository()->getTimesheetsForQuery($query); $entries = $this->repository->getTimesheetsForQuery($query);
$exporter = $this->exportService->getTimesheetExporterById($exporterId); $exporter = $this->exportService->getTimesheetExporterById($exporterId);
@@ -300,13 +280,20 @@ abstract class TimesheetAbstractController extends AbstractController
// remove all, which are not allowed to be edited // remove all, which are not allowed to be edited
$timesheets = []; $timesheets = [];
$disallowed = 0;
/** @var Timesheet $timesheet */ /** @var Timesheet $timesheet */
foreach ($dto->getEntities() as $timesheet) { foreach ($dto->getEntities() as $timesheet) {
if (!$this->isGranted('edit', $timesheet)) { if (!$this->isGranted('edit', $timesheet)) {
$disallowed++;
continue; continue;
} }
$timesheets[] = $timesheet; $timesheets[] = $timesheet;
} }
if ($disallowed > 0) {
$this->flashWarning(sprintf('You are missing the permission to edit %s timesheets', $disallowed));
}
$dto->setEntities($timesheets); $dto->setEntities($timesheets);
if (count($dto->getEntities()) === 0) { if (count($dto->getEntities()) === 0) {
@@ -343,6 +330,7 @@ abstract class TimesheetAbstractController extends AbstractController
$timesheet->setExported($dto->isExported()); $timesheet->setExported($dto->isExported());
$execute = true; $execute = true;
} }
// setting both values allows to erase wrong
if (null !== $dto->getHourlyRate()) { if (null !== $dto->getHourlyRate()) {
$timesheet->setFixedRate(null); $timesheet->setFixedRate(null);
$timesheet->setHourlyRate($dto->getHourlyRate()); $timesheet->setHourlyRate($dto->getHourlyRate());
@@ -411,6 +399,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []), 'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST', 'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()), 'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
'include_user' => $this->includeUserInForms('multi'), 'include_user' => $this->includeUserInForms('multi'),
]); ]);
} }
@@ -424,7 +413,7 @@ abstract class TimesheetAbstractController extends AbstractController
return $this->createForm(MultiUpdateTable::class, $dto, [ return $this->createForm(MultiUpdateTable::class, $dto, [
'action' => $this->generateUrl($this->getTimesheetRoute()), 'action' => $this->generateUrl($this->getTimesheetRoute()),
'repository' => $this->getRepository(), 'repository' => $this->repository,
'method' => 'POST', 'method' => 'POST',
]); ]);
} }
@@ -487,6 +476,11 @@ abstract class TimesheetAbstractController extends AbstractController
return 'edit_export_own_timesheet'; return 'edit_export_own_timesheet';
} }
protected function getPermissionEditRate(): string
{
return 'edit_rate_own_timesheet';
}
protected function getCreateFormClassName(): string protected function getCreateFormClassName(): string
{ {
return TimesheetEditForm::class; return TimesheetEditForm::class;

View File

@@ -108,6 +108,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return 'edit_export_other_timesheet'; return 'edit_export_other_timesheet';
} }
protected function getPermissionEditRate(): string
{
return 'edit_rate_other_timesheet';
}
protected function getCreateFormClassName(): string protected function getCreateFormClassName(): string
{ {
return TimesheetAdminEditForm::class; return TimesheetAdminEditForm::class;

View File

@@ -47,12 +47,12 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return User[] * @return array<int|string, User>
*/ */
protected function getAllUsers(ObjectManager $manager) protected function getAllUsers(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var User[] $entries */ /** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll(); $entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -63,13 +63,13 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Project[] * @return array<int|string, Project>
*/ */
protected function getAllProjects(ObjectManager $manager) protected function getAllProjects(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Project[] $entries */ /** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll(); $entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -35,8 +35,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
public const MIN_TIMESHEETS_PER_USER = 50; public const MIN_TIMESHEETS_PER_USER = 50;
public const MAX_TIMESHEETS_PER_USER = 500; public const MAX_TIMESHEETS_PER_USER = 500;
public const MAX_TIMESHEETS_TOTAL = 5000; public const MAX_TIMESHEETS_TOTAL = 5000;
public const MIN_RUNNING_TIMESHEETS_PER_USER = 0; public const MAX_RUNNING_TIMESHEETS_PER_USER = 2;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 3;
public const TIMERANGE_DAYS = 1095; // 3 years public const TIMERANGE_DAYS = 1095; // 3 years
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
public const MIN_MINUTES_PER_ENTRY = 15; public const MIN_MINUTES_PER_ENTRY = 15;
@@ -79,7 +78,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
// random amount of timesheet entries for every user // random amount of timesheet entries for every user
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER); $timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
for ($i = 1; $i <= $timesheetForUser; $i++) { for ($i = 1; $i <= $timesheetForUser; $i++) {
if ($all > self::MAX_TIMESHEETS_TOTAL) { if ($all > self::MAX_TIMESHEETS_TOTAL && $i > self::MIN_TIMESHEETS_PER_USER) {
break; break;
} }
@@ -109,7 +108,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
} }
// create active recordings for test user // create active recordings for test user
$activeEntries = rand(self::MIN_RUNNING_TIMESHEETS_PER_USER, self::MAX_RUNNING_TIMESHEETS_PER_USER); $activeEntries = rand(0, self::MAX_RUNNING_TIMESHEETS_PER_USER);
for ($i = 0; $i < $activeEntries; $i++) { for ($i = 0; $i < $activeEntries; $i++) {
$entry = $this->createTimesheetEntry( $entry = $this->createTimesheetEntry(
$user, $user,
@@ -144,12 +143,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Tag[] * @return array<int|string, Tag>
*/ */
protected function getAllTags(ObjectManager $manager) protected function getAllTags(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Tag[] $entries */ /** @var Tag[] $entries */
$entries = $manager->getRepository(Tag::class)->findAll(); $entries = $manager->getRepository(Tag::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -160,12 +159,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return User[] * @return array<int|string, User>
*/ */
protected function getAllUsers(ObjectManager $manager) protected function getAllUsers(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var User[] $entries */ /** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll(); $entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -176,12 +175,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Project[] * @return array<int|string, Project>
*/ */
protected function getAllProjects(ObjectManager $manager) protected function getAllProjects(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Project[] $entries */ /** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll(); $entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -192,12 +191,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Activity[] * @return array<int|string, Activity>
*/ */
protected function getAllActivities(ObjectManager $manager) protected function getAllActivities(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Activity[] $entries */ /** @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll(); $entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -33,7 +33,7 @@ class AppExtension extends Extension
throw $e; throw $e;
} }
// @deprecated since 0.9, duration_only will be removed with 1.0 // @deprecated since 0.9, duration_only will be removed with 2.0
if (isset($config['timesheet']['duration_only'])) { if (isset($config['timesheet']['duration_only'])) {
@trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED); @trigger_error('Configuration "kimai.timesheet.duration_only" is deprecated, please remove it', E_USER_DEPRECATED);
if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) { if (true === $config['timesheet']['duration_only'] && 'duration_only' !== $config['timesheet']['mode']) {

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* } * }
* ) * )
* @ORM\Entity(repositoryClass="App\Repository\ProjectRepository") * @ORM\Entity(repositoryClass="App\Repository\ProjectRepository")
* @App\Validator\Constraints\Project
* *
* columns={"customer_id","visible","name"} => IDX_407F12069395C3F37AB0E8595E237E06 => project administration without filter * columns={"customer_id","visible","name"} => IDX_407F12069395C3F37AB0E8595E237E06 => project administration without filter
* columns={"customer_id","visible","id"} => IDX_407F12069395C3F37AB0E859BF396750 => used in joins between project and customer, eg. dropdowns and activity administration page * columns={"customer_id","visible","id"} => IDX_407F12069395C3F37AB0E859BF396750 => used in joins between project and customer, eg. dropdowns and activity administration page
@@ -67,6 +68,28 @@ class Project implements EntityWithMetaFields
* @ORM\Column(name="order_date", type="datetime", nullable=true) * @ORM\Column(name="order_date", type="datetime", nullable=true)
*/ */
private $orderDate; private $orderDate;
/**
* @var \DateTime
*
* @ORM\Column(name="start", type="datetime", nullable=true)
*/
private $start;
/**
* @var \DateTime
*
* @ORM\Column(name="end", type="datetime", nullable=true)
*/
private $end;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=true)
*/
private $timezone;
/**
* @var bool
*/
private $localized = false;
/** /**
* @var string * @var string
* *
@@ -192,8 +215,41 @@ class Project implements EntityWithMetaFields
return $this; return $this;
} }
/**
* Make sure begin and end date have the correct timezone.
* This will be called once for each item after being loaded from the database.
*/
protected function localizeDates()
{
if ($this->localized) {
return;
}
if (null === $this->timezone) {
$this->timezone = date_default_timezone_get();
}
$timezone = new \DateTimeZone($this->timezone);
if (null !== $this->orderDate) {
$this->orderDate->setTimeZone($timezone);
}
if (null !== $this->start) {
$this->start->setTimeZone($timezone);
}
if (null !== $this->end) {
$this->end->setTimeZone($timezone);
}
$this->localized = true;
}
public function getOrderDate(): ?\DateTime public function getOrderDate(): ?\DateTime
{ {
$this->localizeDates();
return $this->orderDate; return $this->orderDate;
} }
@@ -201,6 +257,46 @@ class Project implements EntityWithMetaFields
{ {
$this->orderDate = $orderDate; $this->orderDate = $orderDate;
if (null !== $orderDate) {
$this->timezone = $orderDate->getTimezone()->getName();
}
return $this;
}
public function getStart(): ?\DateTime
{
$this->localizeDates();
return $this->start;
}
public function setStart(?\DateTime $start): Project
{
$this->start = $start;
if (null !== $start) {
$this->timezone = $start->getTimezone()->getName();
}
return $this;
}
public function getEnd(): ?\DateTime
{
$this->localizeDates();
return $this->end;
}
public function setEnd(?\DateTime $end): Project
{
$this->end = $end;
if (null !== $end) {
$this->timezone = $end->getTimezone()->getName();
}
return $this; return $this;
} }

View File

@@ -141,7 +141,7 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
/** /**
* @var Tag[]|ArrayCollection * @var Tag[]|ArrayCollection
* *
* @ORM\ManyToMany(targetEntity="Tag", inversedBy="timesheets", cascade={"persist"}) * @ORM\ManyToMany(targetEntity="App\Entity\Tag", inversedBy="timesheets", cascade={"persist"})
* @ORM\JoinTable( * @ORM\JoinTable(
* name="kimai2_timesheet_tags", * name="kimai2_timesheet_tags",
* joinColumns={ * joinColumns={

View File

@@ -17,6 +17,7 @@ use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent; use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions; use App\Twig\DateExtensions;
use DateTime; use DateTime;
@@ -158,8 +159,10 @@ abstract class AbstractSpreadsheetRenderer
*/ */
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{ {
$customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));

View File

@@ -17,6 +17,7 @@ use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent; use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent; use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -62,8 +63,10 @@ class HtmlRenderer
*/ */
public function render(array $timesheets, TimesheetQuery $query): Response public function render(array $timesheets, TimesheetQuery $query): Response
{ {
$customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));

160
src/Form/FormTrait.php Normal file
View File

@@ -0,0 +1,160 @@
<?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\Form;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
/**
* Defines the form used to manipulate Timesheet entries.
*/
trait FormTrait
{
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
]);
}
protected function addProject(FormBuilderInterface $builder, bool $isNew, ?Project $project = null, ?Customer $customer = null)
{
$builder->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
/** @var Project $project */
$project = $repo->find($project);
if (null !== $project) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
{
$builder
->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
if (!empty($activity) && is_string($activity)) {
$activity = $repo->find($activity);
}
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
}
);
}
protected function addDescription(FormBuilderInterface $builder)
{
$builder
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
'attr' => [
'autofocus' => 'autofocus'
]
]);
}
protected function addTags(FormBuilderInterface $builder)
{
$builder
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'string',
'description' => 'Comma separated list of tags',
],
'required' => false,
]);
}
}

View File

@@ -196,13 +196,15 @@ class TimesheetMultiUpdate extends AbstractType
]); ]);
} }
$builder if ($options['include_rate']) {
->add('fixedRate', FixedRateType::class, [ $builder
'currency' => $currency, ->add('fixedRate', FixedRateType::class, [
]) 'currency' => $currency,
->add('hourlyRate', HourlyRateType::class, [ ])
'currency' => $currency, ->add('hourlyRate', HourlyRateType::class, [
]); 'currency' => $currency,
]);
}
$builder->add('entities', HiddenType::class, [ $builder->add('entities', HiddenType::class, [
'required' => false, 'required' => false,
@@ -241,6 +243,7 @@ class TimesheetMultiUpdate extends AbstractType
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_multiupdate', 'csrf_token_id' => 'timesheet_multiupdate',
'include_user' => false, 'include_user' => false,
'include_rate' => false,
'include_exported' => false, 'include_exported' => false,
]); ]);
} }

View File

@@ -63,6 +63,14 @@ class ProjectEditForm extends AbstractType
'label' => 'label.orderDate', 'label' => 'label.orderDate',
'required' => false, 'required' => false,
]) ])
->add('start', DateTimePickerType::class, [
'label' => 'label.project_start',
'required' => false,
])
->add('end', DateTimePickerType::class, [
'label' => 'label.project_end',
'required' => false,
])
->add('customer', CustomerType::class, [ ->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer); $query = new CustomerFormTypeQuery($customer);

View File

@@ -9,30 +9,20 @@
namespace App\Form; namespace App\Form;
use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType; use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType; use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType; use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType; use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType; use App\Form\Type\UserType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Timesheet\UserDateTimeFactory; use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
@@ -43,6 +33,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/ */
class TimesheetEditForm extends AbstractType class TimesheetEditForm extends AbstractType
{ {
use FormTrait;
/** /**
* @var CustomerRepository * @var CustomerRepository
*/ */
@@ -141,7 +133,7 @@ class TimesheetEditForm extends AbstractType
$this->addCustomer($builder, $customer); $this->addCustomer($builder, $customer);
} }
$this->addProject($builder, $customerCount, $isNew, $project, $customer); $this->addProject($builder, $isNew, $project, $customer);
$this->addActivity($builder, $activity, $project); $this->addActivity($builder, $activity, $project);
$this->addDescription($builder); $this->addDescription($builder);
$this->addTags($builder); $this->addTags($builder);
@@ -169,114 +161,6 @@ class TimesheetEditForm extends AbstractType
return true; return true;
} }
protected function addCustomer(FormBuilderInterface $builder, ?Customer $customer = null)
{
$builder
->add('customer', CustomerType::class, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
'data' => $customer ? $customer : '',
'required' => false,
'placeholder' => '',
'mapped' => false,
'project_enabled' => true,
]);
}
protected function addProject(FormBuilderInterface $builder, int $customerCount, bool $isNew, ?Project $project = null, ?Customer $customer = null)
{
$projectOptions = [];
if ($customerCount < 2) {
$projectOptions['group_by'] = null;
}
$builder
->add(
'project',
ProjectType::class,
array_merge($projectOptions, [
'placeholder' => '',
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer) {
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
])
);
// replaces the project select after submission, to make sure only projects for the selected customer are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $project, $customer, $isNew) {
$data = $event->getData();
$customer = isset($data['customer']) && !empty($data['customer']) ? $data['customer'] : null;
$project = isset($data['project']) && !empty($data['project']) ? $data['project'] : $project;
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {
// is there a better wa to prevent starting a record with a hidden project ?
if ($isNew && !empty($project) && (is_int($project) || is_string($project))) {
/** @var Project $project */
$project = $repo->find($project);
if (null !== $project) {
if (!$project->getCustomer()->isVisible()) {
$customer = null;
$project = null;
} elseif (!$project->isVisible()) {
$project = null;
}
}
}
$query = new ProjectFormTypeQuery($project, $customer);
$query->setUser($builder->getOption('user'));
return $repo->getQueryBuilderForFormType($query);
},
]);
}
);
}
protected function addActivity(FormBuilderInterface $builder, ?Activity $activity = null, ?Project $project = null)
{
$builder
->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($activity, $project) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $project));
},
])
;
// replaces the activity select after submission, to make sure only activities for the selected project are displayed
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($activity) {
$data = $event->getData();
if (!isset($data['project']) || empty($data['project'])) {
return;
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->getQueryBuilderForFormType(new ActivityFormTypeQuery($activity, $data['project']));
},
]);
}
);
}
protected function addBegin(FormBuilderInterface $builder, array $dateTimeOptions) protected function addBegin(FormBuilderInterface $builder, array $dateTimeOptions)
{ {
$builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [ $builder->add('begin', DateTimePickerType::class, array_merge($dateTimeOptions, [
@@ -330,31 +214,6 @@ class TimesheetEditForm extends AbstractType
); );
} }
protected function addDescription(FormBuilderInterface $builder)
{
$builder
->add('description', TextareaType::class, [
'label' => 'label.description',
'required' => false,
'attr' => [
'autofocus' => 'autofocus'
]
]);
}
protected function addTags(FormBuilderInterface $builder)
{
$builder
->add('tags', TagsInputType::class, [
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'string',
'description' => 'Comma separated list of tags for this timesheet record',
],
'required' => false,
]);
}
protected function addRates(FormBuilderInterface $builder, $currency, array $options) protected function addRates(FormBuilderInterface $builder, $currency, array $options)
{ {
if (!$options['include_rate']) { if (!$options['include_rate']) {

View File

@@ -71,18 +71,20 @@ abstract class AbstractToolbarForm extends AbstractType
]); ]);
} }
protected function addCustomerChoice(FormBuilderInterface $builder, bool $required = false) protected function addCustomerChoice(FormBuilderInterface $builder, array $options = [])
{ {
// just a fake field for having this field at the right position in the frontend // just a fake field for having this field at the right position in the frontend
$builder->add('customer', HiddenType::class); $builder->add('customer', HiddenType::class);
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $required) { function (FormEvent $event) use ($builder, $options) {
$data = $event->getData(); $data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [ $event->getForm()->add('customer', CustomerType::class, array_merge([
'required' => $required, 'required' => false,
'project_enabled' => true, 'project_enabled' => true,
'end_date_param' => '%daterange%',
'start_date_param' => '%daterange%',
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) { 'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery(); $query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user')); $query->setUser($builder->getOption('user'));
@@ -92,7 +94,7 @@ abstract class AbstractToolbarForm extends AbstractType
return $repo->getQueryBuilderForFormType($query); return $repo->getQueryBuilderForFormType($query);
}, },
]); ], $options));
} }
); );
} }
@@ -130,19 +132,19 @@ abstract class AbstractToolbarForm extends AbstractType
]); ]);
} }
protected function addProjectChoice(FormBuilderInterface $builder) protected function addProjectChoice(FormBuilderInterface $builder, array $options = [])
{ {
// just a fake field for having this field at the right position in the frontend // just a fake field for having this field at the right position in the frontend
$builder->add('project', HiddenType::class); $builder->add('project', HiddenType::class);
$builder->addEventListener( $builder->addEventListener(
FormEvents::PRE_SUBMIT, FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder) { function (FormEvent $event) use ($builder, $options) {
$data = $event->getData(); $data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [ $event->getForm()->add('project', ProjectType::class, array_merge([
'required' => false, 'required' => false,
'activity_enabled' => true, 'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) { 'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options) {
$query = new ProjectFormTypeQuery(); $query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user')); $query->setUser($builder->getOption('user'));
@@ -152,10 +154,13 @@ abstract class AbstractToolbarForm extends AbstractType
if (isset($data['project']) && !empty($data['project'])) { if (isset($data['project']) && !empty($data['project'])) {
$query->setProject($data['project']); $query->setProject($data['project']);
} }
if (isset($options['ignore_date']) && true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query); return $repo->getQueryBuilderForFormType($query);
}, },
]); ], $options));
} }
); );
} }
@@ -175,7 +180,12 @@ abstract class AbstractToolbarForm extends AbstractType
$query = new ActivityFormTypeQuery(); $query = new ActivityFormTypeQuery();
if (isset($data['activity']) && !empty($data['activity'])) { if (isset($data['activity']) && !empty($data['activity'])) {
$query->setActivity($data['activity']); $activity = $data['activity'];
if (is_string($data['activity'])) {
$activity = $repo->find($data['activity']);
}
$query->setActivity($activity);
} }
if (isset($data['project']) && !empty($data['project'])) { if (isset($data['project']) && !empty($data['project'])) {
$query->setProject($data['project']); $query->setProject($data['project']);

View File

@@ -31,8 +31,8 @@ class ExportToolbarForm extends AbstractToolbarForm
$this->addTimesheetStateChoice($builder); $this->addTimesheetStateChoice($builder);
$this->addUsersChoice($builder); $this->addUsersChoice($builder);
$this->addDateRangeChoice($builder); $this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder); $this->addCustomerChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true]);
$this->addProjectChoice($builder); $this->addProjectChoice($builder, ['ignore_date' => true]);
$this->addActivityChoice($builder); $this->addActivityChoice($builder);
$this->addExportType($builder); $this->addExportType($builder);
$this->addTagInputField($builder); $this->addTagInputField($builder);

View File

@@ -32,8 +32,8 @@ class InvoiceToolbarForm extends AbstractToolbarForm
$this->addUsersChoice($builder); $this->addUsersChoice($builder);
} }
$this->addDateRangeChoice($builder); $this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, true); $this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
$this->addProjectChoice($builder); $this->addProjectChoice($builder, ['ignore_date' => true]);
$this->addActivityChoice($builder); $this->addActivityChoice($builder);
$this->addTagInputField($builder); $this->addTagInputField($builder);
$this->addExportStateChoice($builder); $this->addExportStateChoice($builder);

View File

@@ -39,6 +39,9 @@ class CustomerType extends AbstractType
'choice_label' => 'name', 'choice_label' => 'name',
'query_builder_for_user' => true, 'query_builder_for_user' => true,
'project_enabled' => false, 'project_enabled' => false,
'start_date_param' => '%begin%',
'end_date_param' => '%end%',
'ignore_date' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE, 'project_visibility' => ProjectQuery::SHOW_VISIBLE,
]); ]);
@@ -55,11 +58,29 @@ class CustomerType extends AbstractType
$resolver->setDefault('api_data', function (Options $options) { $resolver->setDefault('api_data', function (Options $options) {
if (true === $options['project_enabled']) { if (true === $options['project_enabled']) {
$routeParams = ['customer' => '%customer%', 'visible' => $options['project_visibility']];
$emptyRouteParams = ['visible' => $options['project_visibility']];
if (!$options['ignore_date']) {
if (!empty($options['start_date_param'])) {
$routeParams['start'] = $options['start_date_param'];
$emptyRouteParams['start'] = $options['start_date_param'];
}
if (!empty($options['end_date_param'])) {
$routeParams['end'] = $options['end_date_param'];
$emptyRouteParams['end'] = $options['end_date_param'];
}
} else {
$routeParams['ignoreDates'] = 1;
$emptyRouteParams['ignoreDates'] = 1;
}
return [ return [
'select' => 'project', 'select' => 'project',
'route' => 'get_projects', 'route' => 'get_projects',
'route_params' => ['customer' => '-s-', 'visible' => $options['project_visibility']], 'route_params' => $routeParams,
'empty_route_params' => ['visible' => $options['project_visibility']], 'empty_route_params' => $emptyRouteParams,
]; ];
} }

View File

@@ -65,6 +65,7 @@ class ProjectType extends AbstractType
'query_builder_for_user' => true, 'query_builder_for_user' => true,
'activity_enabled' => false, 'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE, 'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
]); ]);
$resolver->setDefault('query_builder', function (Options $options) { $resolver->setDefault('query_builder', function (Options $options) {
@@ -73,6 +74,9 @@ class ProjectType extends AbstractType
if (true === $options['query_builder_for_user']) { if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']); $query->setUser($options['user']);
} }
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query); return $repo->getQueryBuilderForFormType($query);
}; };
@@ -83,7 +87,7 @@ class ProjectType extends AbstractType
return [ return [
'select' => 'activity', 'select' => 'activity',
'route' => 'get_activities', 'route' => 'get_activities',
'route_params' => ['project' => '-s-', 'visible' => $options['activity_visibility']], 'route_params' => ['project' => '%project%', 'visible' => $options['activity_visibility']],
'empty_route_params' => ['globals' => 'true', 'visible' => $options['activity_visibility']], 'empty_route_params' => ['globals' => 'true', 'visible' => $options['activity_visibility']],
]; ];
} }

View File

@@ -10,9 +10,12 @@
namespace App\Form\Type; namespace App\Form\Type;
use App\Entity\Team; use App\Entity\Team;
use App\Entity\User;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamType extends AbstractType class TeamType extends AbstractType
@@ -25,13 +28,26 @@ class TeamType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'class' => Team::class, 'class' => Team::class,
'label' => 'label.team', 'label' => 'label.team',
'query_builder' => function (TeamRepository $repo) { 'teamlead_only' => true,
return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
},
'choice_label' => function (Team $team) { 'choice_label' => function (Team $team) {
return $team->getName(); return $team->getName();
}, },
]); ]);
$resolver->setDefault('query_builder', function (Options $options) {
return function (TeamRepository $repo) use ($options) {
/** @var User $user */
$user = $options['user'];
$query = new TeamQuery();
$query->setCurrentUser($user);
if (!$options['teamlead_only']) {
$query->setTeams($user->getTeams()->toArray());
}
return $repo->getQueryBuilderForFormType($query);
};
});
} }
/** /**

View File

@@ -9,7 +9,7 @@
namespace App\Form\Type; namespace App\Form\Type;
use App\Repository\Query\VisibilityQuery; use App\Repository\Query\VisibilityInterface;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,9 +27,9 @@ class VisibilityType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'label' => 'label.visible', 'label' => 'label.visible',
'choices' => [ 'choices' => [
'both' => VisibilityQuery::SHOW_BOTH, 'both' => VisibilityInterface::SHOW_BOTH,
'yes' => VisibilityQuery::SHOW_VISIBLE, 'yes' => VisibilityInterface::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN, 'no' => VisibilityInterface::SHOW_HIDDEN,
], ],
]); ]);
} }

View File

@@ -104,7 +104,7 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
$invoiceItem->setProject($entry->getProject()); $invoiceItem->setProject($entry->getProject());
} }
if (empty($invoiceItem->getDescription())) { if (empty($invoiceItem->getDescription()) && null !== $entry->getActivity()) {
$invoiceItem->setDescription($entry->getActivity()->getName()); $invoiceItem->setDescription($entry->getActivity()->getName());
} }
} }

View File

@@ -20,6 +20,10 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
{ {
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
{ {
if (null === $invoiceItem->getActivity()) {
throw new \Exception('Cannot work with invoice items that do not have an activity');
}
if (null === $invoiceItem->getActivity()->getId()) { if (null === $invoiceItem->getActivity()->getId()) {
throw new \Exception('Cannot handle un-persisted activities'); throw new \Exception('Cannot handle un-persisted activities');
} }

View File

@@ -364,25 +364,23 @@ final class InvoiceModel
$amount = $invoiceItem->getAmount(); $amount = $invoiceItem->getAmount();
} }
if (empty($description)) { $activity = $invoiceItem->getActivity();
$description = $invoiceItem->getActivity()->getName(); $project = $invoiceItem->getProject();
} $customer = $project->getCustomer();
$currency = $customer->getCurrency();
$user = $invoiceItem->getUser(); $user = $invoiceItem->getUser();
$begin = $invoiceItem->getBegin();
$end = $invoiceItem->getEnd();
if (empty($description) && null !== $activity) {
$description = $activity->getName();
}
// this should never happen! // this should never happen!
if (empty($appliedRate)) { if (empty($appliedRate)) {
$appliedRate = 0; $appliedRate = 0;
} }
$activity = $invoiceItem->getActivity();
$project = $invoiceItem->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$begin = $invoiceItem->getBegin();
$end = $invoiceItem->getEnd();
$values = [ $values = [
'entry.row' => '', 'entry.row' => '',
'entry.description' => $description, 'entry.description' => $description,

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds project_start and project_end to projects tables
*
* @version 1.7
*/
final class Version20191204120823 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds project_start and project_end to projects tables';
}
public function up(Schema $schema): void
{
$projects = $schema->getTable('kimai2_projects');
$projects->addColumn('start', 'datetime', ['notnull' => false]);
$projects->addColumn('end', 'datetime', ['notnull' => false]);
$projects->addColumn('timezone', 'string', ['notnull' => false, 'length' => 64]);
}
public function down(Schema $schema): void
{
$projects = $schema->getTable('kimai2_projects');
$projects->dropColumn('timezone');
$projects->dropColumn('end');
$projects->dropColumn('start');
}
}

View File

@@ -182,6 +182,36 @@ class ProjectRepository extends EntityRepository
$qb->andWhere($qb->expr()->eq('p.visible', ':visible')); $qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible')); $qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));
if (!$query->isIgnoreDate()) {
$now = new \DateTime();
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $now);
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $now);
}
$qb->setParameter('visible', true, \PDO::PARAM_BOOL); $qb->setParameter('visible', true, \PDO::PARAM_BOOL);
$qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL); $qb->setParameter('customer_visible', true, \PDO::PARAM_BOOL);
@@ -246,6 +276,47 @@ class ProjectRepository extends EntityRepository
->setParameter('customer', $query->getCustomer()); ->setParameter('customer', $query->getCustomer());
} }
// this is far from being perfect, possible enhancements:
// there could also be a range selection to be able to select all projects that were active between from and to
// begin = null and end = null
// begin = null and end <= to
// begin < to and end = null
// begin > from and end < to
// ... and more ...
$begin = $query->getProjectStart();
$end = $query->getProjectEnd();
if (null !== $begin) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':start'),
$qb->expr()->isNull('p.start')
),
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':start'),
$qb->expr()->isNull('p.end')
)
)
)->setParameter('start', $query->getProjectStart());
}
if (null !== $end) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->orX(
$qb->expr()->gte('p.end', ':end'),
$qb->expr()->isNull('p.end')
),
$qb->expr()->orX(
$qb->expr()->lte('p.start', ':end'),
$qb->expr()->isNull('p.start')
)
)
)->setParameter('end', $query->getProjectEnd());
}
$this->addPermissionCriteria($qb, $query->getCurrentUser()); $this->addPermissionCriteria($qb, $query->getCurrentUser());
if ($query->hasSearchTerm()) { if ($query->hasSearchTerm()) {

View File

@@ -92,6 +92,12 @@ final class ActivityFormTypeQuery
public function isGlobalsOnly(): bool public function isGlobalsOnly(): bool
{ {
return null === $this->activity && null === $this->project; return
(
null === $this->activity ||
($this->activity instanceof Activity && null === $this->activity->getProject())
)
&&
null === $this->project;
} }
} }

View File

@@ -26,15 +26,15 @@ class BaseQuery
public const DEFAULT_PAGE = 1; public const DEFAULT_PAGE = 1;
/** /**
* @deprecated since 1.4, will be removed with 1.6 * @deprecated since 1.4, will be removed with 2.0
*/ */
public const RESULT_TYPE_OBJECTS = 'Objects'; public const RESULT_TYPE_OBJECTS = 'Objects';
/** /**
* @deprecated since 1.4, will be removed with 1.6 * @deprecated since 1.4, will be removed with 2.0
*/ */
public const RESULT_TYPE_PAGER = 'PagerFanta'; public const RESULT_TYPE_PAGER = 'PagerFanta';
/** /**
* @deprecated since 1.4, will be removed with 1.6 * @deprecated since 1.4, will be removed with 2.0
*/ */
public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder'; public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
@@ -63,7 +63,7 @@ class BaseQuery
private $order = self::ORDER_ASC; private $order = self::ORDER_ASC;
/** /**
* @var string * @var string
* @deprecated since 1.4, will be removed with 1.6 * @deprecated since 1.4, will be removed with 2.0
*/ */
private $resultType = self::RESULT_TYPE_PAGER; private $resultType = self::RESULT_TYPE_PAGER;
/** /**
@@ -79,6 +79,23 @@ class BaseQuery
*/ */
private $searchTerm; private $searchTerm;
/**
* @param Team[] $teams
* @return $this
*/
public function setTeams(?array $teams): self
{
$this->teams = [];
if (null !== $teams) {
foreach ($teams as $team) {
$this->addTeam($team);
}
}
return $this;
}
public function addTeam(Team $team): self public function addTeam(Team $team): self
{ {
$this->teams[$team->getId()] = $team; $this->teams[$team->getId()] = $team;
@@ -189,7 +206,7 @@ class BaseQuery
*/ */
public function getResultType() public function getResultType()
{ {
@trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 1.6', E_USER_DEPRECATED); @trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
return $this->resultType; return $this->resultType;
} }
@@ -254,4 +271,27 @@ class BaseQuery
return $this; return $this;
} }
public function copyTo(BaseQuery $query): BaseQuery
{
$query->setDefaults($this->defaults);
if (null !== $this->getCurrentUser()) {
$query->setCurrentUser($this->getCurrentUser());
}
$query->setOrder($this->getOrder());
$query->setOrderBy($this->getOrderBy());
$query->setSearchTerm($this->getSearchTerm());
$query->setPage($this->getPage());
$query->setPageSize($this->getPageSize());
foreach ($this->getTeams() as $team) {
$query->addTeam($team);
}
if ($this instanceof VisibilityInterface && $query instanceof VisibilityInterface) {
$query->setVisibility($this->getVisibility());
}
return $query;
}
} }

View File

@@ -12,8 +12,10 @@ namespace App\Repository\Query;
/** /**
* Can be used for advanced queries with the: CustomerRepository * Can be used for advanced queries with the: CustomerRepository
*/ */
class CustomerQuery extends VisibilityQuery class CustomerQuery extends BaseQuery implements VisibilityInterface
{ {
use VisibilityTrait;
public const CUSTOMER_ORDER_ALLOWED = ['id', 'name', 'comment', 'country', 'number']; public const CUSTOMER_ORDER_ALLOWED = ['id', 'name', 'comment', 'country', 'number'];
public function __construct() public function __construct()

View File

@@ -36,6 +36,10 @@ final class ProjectFormTypeQuery
* @var array<Team> * @var array<Team>
*/ */
private $teams = []; private $teams = [];
/**
* @var bool
*/
private $ignoreDate = false;
/** /**
* @param Project|int|null $project * @param Project|int|null $project
@@ -126,4 +130,16 @@ final class ProjectFormTypeQuery
return $this; return $this;
} }
public function isIgnoreDate(): bool
{
return $this->ignoreDate;
}
public function setIgnoreDate(bool $ignoreDate): ProjectFormTypeQuery
{
$this->ignoreDate = $ignoreDate;
return $this;
}
} }

View File

@@ -14,18 +14,27 @@ use App\Entity\Customer;
/** /**
* Can be used for advanced queries with the: ProjectRepository * Can be used for advanced queries with the: ProjectRepository
*/ */
class ProjectQuery extends CustomerQuery class ProjectQuery extends BaseQuery implements VisibilityInterface
{ {
public const PROJECT_ORDER_ALLOWED = ['id', 'name', 'comment', 'customer', 'orderNumber']; use VisibilityTrait;
public const PROJECT_ORDER_ALLOWED = ['id', 'name', 'comment', 'customer', 'orderNumber', 'projectStart', 'projectEnd'];
/** /**
* @var Customer|int|null * @var Customer|int|null
*/ */
private $customer; private $customer;
/**
* @var \DateTime
*/
private $projectStart;
/**
* @var \DateTime
*/
private $projectEnd;
public function __construct() public function __construct()
{ {
parent::__construct();
$this->setDefaults([ $this->setDefaults([
'orderBy' => 'name', 'orderBy' => 'name',
]); ]);
@@ -49,4 +58,28 @@ class ProjectQuery extends CustomerQuery
return $this; return $this;
} }
public function getProjectStart(): ?\DateTime
{
return $this->projectStart;
}
public function setProjectStart(?\DateTime $projectStart): ProjectQuery
{
$this->projectStart = $projectStart;
return $this;
}
public function getProjectEnd(): ?\DateTime
{
return $this->projectEnd;
}
public function setProjectEnd(?\DateTime $projectEnd): ProjectQuery
{
$this->projectEnd = $projectEnd;
return $this;
}
} }

View File

@@ -12,8 +12,10 @@ namespace App\Repository\Query;
/** /**
* Can be used for advanced queries with the: UserRepository * Can be used for advanced queries with the: UserRepository
*/ */
class UserQuery extends VisibilityQuery class UserQuery extends BaseQuery implements VisibilityInterface
{ {
use VisibilityTrait;
public const USER_ORDER_ALLOWED = ['id', 'alias', 'username', 'title', 'email']; public const USER_ORDER_ALLOWED = ['id', 'alias', 'username', 'title', 'email'];
/** /**

View File

@@ -0,0 +1,31 @@
<?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\Repository\Query;
interface VisibilityInterface
{
public const SHOW_VISIBLE = 1;
public const SHOW_HIDDEN = 2;
public const SHOW_BOTH = 3;
public const ALLOWED_VISIBILITY_STATES = [
self::SHOW_BOTH,
self::SHOW_VISIBLE,
self::SHOW_HIDDEN,
];
public function getVisibility(): int;
/**
* @param int $visibility
* @return mixed
*/
public function setVisibility($visibility);
}

View File

@@ -11,43 +11,10 @@ namespace App\Repository\Query;
/** /**
* Query class for Repositories with a visibility field. * Query class for Repositories with a visibility field.
*
* @deprecated since 1.7, will be removed with 2.0
*/ */
class VisibilityQuery extends BaseQuery class VisibilityQuery extends BaseQuery implements VisibilityInterface
{ {
public const SHOW_VISIBLE = 1; use VisibilityTrait;
public const SHOW_HIDDEN = 2;
public const SHOW_BOTH = 3;
public const ALLOWED_VISIBILITY_STATES = [
self::SHOW_BOTH,
self::SHOW_VISIBLE,
self::SHOW_HIDDEN,
];
/**
* @var int
*/
private $visibility = self::SHOW_VISIBLE;
/**
* @return int
*/
public function getVisibility()
{
return $this->visibility;
}
/**
* @param int $visibility
* @return $this
*/
public function setVisibility($visibility)
{
$visibility = (int) $visibility;
if (in_array($visibility, self::ALLOWED_VISIBILITY_STATES, true)) {
$this->visibility = $visibility;
}
return $this;
}
} }

View File

@@ -0,0 +1,33 @@
<?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\Repository\Query;
trait VisibilityTrait
{
/**
* @var int
*/
private $visibility = VisibilityInterface::SHOW_VISIBLE;
public function getVisibility(): int
{
return $this->visibility;
}
public function setVisibility($visibility)
{
$visibility = (int) $visibility;
if (in_array($visibility, VisibilityInterface::ALLOWED_VISIBILITY_STATES, true)) {
$this->visibility = $visibility;
}
return $this;
}
}

View File

@@ -61,6 +61,25 @@ class TeamRepository extends EntityRepository
$entityManager->flush(); $entityManager->flush();
} }
/**
* Returns a query builder that is used for TeamType and your own 'query_builder' option.
*
* @param TeamQuery $query
* @return QueryBuilder
*/
public function getQueryBuilderForFormType(TeamQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t')
->from(Team::class, 't')
->orderBy('t.name', 'ASC');
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
return $qb;
}
public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta public function getPagerfantaForQuery(TeamQuery $query): Pagerfanta
{ {
$paginator = new Pagerfanta($this->getPaginatorForQuery($query)); $paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -134,6 +153,11 @@ class TeamRepository extends EntityRepository
return $qb; return $qb;
} }
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{ {
// make sure that all queries without a user see all user // make sure that all queries without a user see all user
@@ -146,10 +170,21 @@ class TeamRepository extends EntityRepository
return; return;
} }
$or = $qb->expr()->orX();
if (null !== $user) { if (null !== $user) {
$qb $or->add($qb->expr()->eq('t.teamlead', ':id'));
->andWhere('t.teamlead = :id') $qb->setParameter('id', $user);
->setParameter('id', $user);
} }
if (!empty($teams)) {
$ids = [];
foreach ($teams as $team) {
$ids[] = $team->getId();
}
$or->add($qb->expr()->in('t.id', $ids));
}
$qb->andWhere($or);
} }
} }

View File

@@ -827,6 +827,11 @@ class TimesheetRepository extends EntityRepository
private function getDatetimeFieldSql(string $field): string private function getDatetimeFieldSql(string $field): string
{ {
return sprintf('CONVERT_TZ(%s, \'UTC\', t.timezone)', $field); // this would change the selected data for queries that join across multiple timezones
// but due to tax laws, this is disabled - exports/invoices should *always* include the data from
// the own timezone, not from the original users timezone
// return sprintf('CONVERT_TZ(%s, \'UTC\', t.timezone)', $field);
return $field;
} }
} }

View File

@@ -89,7 +89,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
*/ */
public function findByQuery(UserQuery $query) public function findByQuery(UserQuery $query)
{ {
@trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 1.6', E_USER_DEPRECATED); @trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) { if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) {

View File

@@ -0,0 +1,118 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Timesheet;
use App\Configuration\TimesheetConfiguration;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Repository\TimesheetRepository;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class TimesheetService
{
/**
* @var TimesheetRepository
*/
private $repository;
/**
* @var TimesheetConfiguration
*/
private $configuration;
/**
* @var TrackingModeService
*/
private $trackingModeService;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var AuthorizationCheckerInterface
*/
private $auth;
public function __construct(
TimesheetConfiguration $configuration,
TimesheetRepository $repository,
TrackingModeService $service,
EventDispatcherInterface $dispatcher,
AuthorizationCheckerInterface $security
) {
$this->configuration = $configuration;
$this->repository = $repository;
$this->trackingModeService = $service;
$this->dispatcher = $dispatcher;
$this->auth = $security;
}
/**
* Calls prepareNewTimesheet() automatically.
*
* @param User $user
* @param Request|null $request
* @return Timesheet
*/
public function createNewTimesheet(User $user, ?Request $request = null): Timesheet
{
$timesheet = new Timesheet();
$timesheet->setUser($user);
if (null !== $request) {
$this->prepareNewTimesheet($timesheet, $request);
}
return $timesheet;
}
public function prepareNewTimesheet(Timesheet $timesheet, ?Request $request = null)
{
if (null !== $timesheet->getId()) {
throw new \InvalidArgumentException('Cannot prepare timesheet, already persisted');
}
$event = new TimesheetMetaDefinitionEvent($timesheet);
$this->dispatcher->dispatch($event);
$mode = $this->trackingModeService->getActiveMode();
$mode->create($timesheet, $request);
return $timesheet;
}
public function saveNewTimesheet(Timesheet $timesheet)
{
if (null !== $timesheet->getId()) {
throw new \InvalidArgumentException('Cannot create timesheet, already persisted');
}
if (null === $timesheet->getEnd()) {
if (!$this->auth->isGranted('start', $timesheet)) {
throw new AccessDeniedHttpException('You are not allowed to start this timesheet record');
}
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->configuration->getActiveEntriesHardLimit()
);
}
$this->repository->save($timesheet);
return $timesheet;
}
public function stopTimesheet(Timesheet $timesheet)
{
return $this->repository->stopRecording($timesheet);
}
}

View File

@@ -31,8 +31,12 @@ abstract class AbstractTrackingMode implements TrackingModeInterface
$this->configuration = $configuration; $this->configuration = $configuration;
} }
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, ?Request $request = null): void
{ {
if (null === $request) {
return;
}
$this->setBeginEndFromRequest($timesheet, $request); $this->setBeginEndFromRequest($timesheet, $request);
$this->setFromToFromRequest($timesheet, $request); $this->setFromToFromRequest($timesheet, $request);
} }

View File

@@ -58,7 +58,7 @@ final class DefaultMode extends AbstractTrackingMode
return true; return true;
} }
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, ?Request $request = null): void
{ {
parent::create($timesheet, $request); parent::create($timesheet, $request);

View File

@@ -51,7 +51,7 @@ final class DurationFixedBeginMode implements TrackingModeInterface
return false; return false;
} }
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, ?Request $request = null): void
{ {
if (null === $timesheet->getBegin()) { if (null === $timesheet->getBegin()) {
$timesheet->setBegin($this->dateTime->createDateTime()); $timesheet->setBegin($this->dateTime->createDateTime());

View File

@@ -44,7 +44,7 @@ final class DurationOnlyMode extends AbstractTrackingMode
return false; return false;
} }
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, ?Request $request = null): void
{ {
if (null === $timesheet->getBegin()) { if (null === $timesheet->getBegin()) {
$timesheet->setBegin($this->dateTime->createDateTime()); $timesheet->setBegin($this->dateTime->createDateTime());

View File

@@ -45,7 +45,7 @@ final class PunchInOutMode implements TrackingModeInterface
return false; return false;
} }
public function create(Timesheet $timesheet, Request $request): void public function create(Timesheet $timesheet, ?Request $request = null): void
{ {
if (null === $timesheet->getBegin()) { if (null === $timesheet->getBegin()) {
$timesheet->setBegin($this->dateTime->createDateTime()); $timesheet->setBegin($this->dateTime->createDateTime());

View File

@@ -15,6 +15,8 @@ use Symfony\Component\HttpFoundation\Request;
/** /**
* A tracking-mode defines the behaviour of the user timesheet. * A tracking-mode defines the behaviour of the user timesheet.
* It is NOT used for the timesheet administration. * It is NOT used for the timesheet administration.
*
* @internal do not implement this interface in your bundle, but rather drop a PR to add it to Kimai core
*/ */
interface TrackingModeInterface interface TrackingModeInterface
{ {
@@ -23,9 +25,9 @@ interface TrackingModeInterface
* before form data is rendered/processed. * before form data is rendered/processed.
* *
* @param Timesheet $timesheet * @param Timesheet $timesheet
* @param Request $request * @param Request|null $request
*/ */
public function create(Timesheet $timesheet, Request $request): void; public function create(Timesheet $timesheet, ?Request $request = null): void;
/** /**
* Whether the user can edit the begin datetime. * Whether the user can edit the begin datetime.

View File

@@ -38,6 +38,7 @@ final class IconExtension extends AbstractExtension
'download' => 'fas fa-download', 'download' => 'fas fa-download',
'duration' => 'far fa-hourglass', 'duration' => 'far fa-hourglass',
'edit' => 'far fa-edit', 'edit' => 'far fa-edit',
'end' => 'fas fa-stopwatch',
'export' => 'fas fa-file-export', 'export' => 'fas fa-file-export',
'filter' => 'fas fa-filter', 'filter' => 'fas fa-filter',
'help' => 'far fa-question-circle', 'help' => 'far fa-question-circle',
@@ -55,6 +56,8 @@ final class IconExtension extends AbstractExtension
'off' => 'fas fa-toggle-off', 'off' => 'fas fa-toggle-off',
'on' => 'fas fa-toggle-on', 'on' => 'fas fa-toggle-on',
'pdf' => 'fas fa-file-pdf', 'pdf' => 'fas fa-file-pdf',
'pause' => 'fas fa-pause',
'pause-small' => 'far fa-pause-circle',
'permissions' => 'fas fa-user-lock', 'permissions' => 'fas fa-user-lock',
'phone' => 'fas fa-phone', 'phone' => 'fas fa-phone',
'plugin' => 'fas fa-plug', 'plugin' => 'fas fa-plug',
@@ -67,7 +70,7 @@ final class IconExtension extends AbstractExtension
'search' => 'fas fa-search', 'search' => 'fas fa-search',
'settings' => 'fas fa-cog', 'settings' => 'fas fa-cog',
'shop' => 'fas fa-shopping-cart', 'shop' => 'fas fa-shopping-cart',
'start' => 'fas fa-play-circle', 'start' => 'fas fa-play',
'start-small' => 'far fa-play-circle', 'start-small' => 'far fa-play-circle',
'stop' => 'fas fa-stop', 'stop' => 'fas fa-stop',
'stop-small' => 'far fa-stop-circle', 'stop-small' => 'far fa-stop-circle',

View File

@@ -0,0 +1,33 @@
<?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\Validator\Constraints;
use Doctrine\Common\Annotations\Annotation\Target;
use Symfony\Component\Validator\Constraint;
/**
* @Annotation
* @Target({"CLASS", "PROPERTY", "METHOD", "ANNOTATION"})
*/
class Project extends Constraint
{
public const END_BEFORE_BEGIN_ERROR = 'kimai-project-00';
protected static $errorNames = [
self::END_BEFORE_BEGIN_ERROR => 'End date must not be earlier then start date.',
];
public $message = 'This project has invalid settings.';
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}

View 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\Validator\Constraints;
use App\Entity\Project;
use App\Validator\Constraints\Project as ProjectConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class ProjectValidator extends ConstraintValidator
{
/**
* @param Project|mixed $value
* @param Constraint $constraint
*/
public function validate($value, Constraint $constraint)
{
if (!($constraint instanceof ProjectConstraint)) {
throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Project');
}
if (!is_object($value) || !($value instanceof Project)) {
return;
}
$this->validateProject($value, $this->context);
}
protected function validateProject(Project $project, ExecutionContextInterface $context)
{
if (null !== $project->getStart() && null !== $project->getEnd() && $project->getStart()->getTimestamp() > $project->getEnd()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(ProjectConstraint::END_BEFORE_BEGIN_ERROR)
->addViolation();
}
}
}

View File

@@ -18,16 +18,18 @@ use Symfony\Component\Validator\Constraint;
*/ */
class Timesheet extends Constraint class Timesheet extends Constraint
{ {
public const MISSING_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d81'; public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d82'; public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const BEGIN_IN_FUTURE_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d83'; public const BEGIN_IN_FUTURE_ERROR = 'kimai-timesheet-83';
public const MISSING_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d84'; public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d85'; public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d86'; public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87'; public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88'; public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89'; public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
public const START_DISALLOWED = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d90'; public const START_DISALLOWED = 'kimai-timesheet-90';
public const PROJECT_NOT_STARTED = 'kimai-timesheet-91';
public const PROJECT_ALREADY_ENDED = 'kimai-timesheet-92';
protected static $errorNames = [ protected static $errorNames = [
self::MISSING_BEGIN_ERROR => 'You must submit a begin date.', self::MISSING_BEGIN_ERROR => 'You must submit a begin date.',
@@ -40,6 +42,8 @@ class Timesheet extends Constraint
self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.', self::DISABLED_PROJECT_ERROR => 'Cannot start a disabled project.',
self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.', self::DISABLED_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
self::START_DISALLOWED => 'You are not allowed to start this timesheet record.', self::START_DISALLOWED => 'You are not allowed to start this timesheet record.',
self::PROJECT_NOT_STARTED => 'The project has not started at that time.',
self::PROJECT_ALREADY_ENDED => 'The project is finished at that time.',
]; ];
public $message = 'This timesheet has invalid settings.'; public $message = 'This timesheet has invalid settings.';

View File

@@ -166,7 +166,9 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && !$activity->isVisible()) { $timesheetEnd = $timesheet->getEnd();
if (null === $timesheetEnd && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.') $context->buildViolation('Cannot start a disabled activity.')
->atPath('activity') ->atPath('activity')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
@@ -174,7 +176,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && !$project->isVisible()) { if (null === $timesheetEnd && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.') $context->buildViolation('Cannot start a disabled project.')
->atPath('project') ->atPath('project')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
@@ -182,12 +184,52 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && !$project->getCustomer()->isVisible()) { if (null === $timesheetEnd && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.') $context->buildViolation('Cannot start a disabled customer.')
->atPath('customer') ->atPath('customer')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR) ->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->addViolation(); ->addViolation();
} }
$projectBegin = $project->getStart();
$projectEnd = $project->getEnd();
if (null !== $projectBegin || null !== $projectEnd) {
$timesheetStart = $timesheet->getBegin();
$timesheetEnd = $timesheet->getEnd();
if (null !== $timesheetStart) {
if (null !== $projectBegin && $timesheetStart->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
} elseif (null !== $projectEnd && $timesheetStart->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
}
}
if (null !== $timesheetEnd) {
if (null !== $projectEnd && $timesheetEnd->getTimestamp() > $projectEnd->getTimestamp()) {
$context->buildViolation('The project is finished at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_ALREADY_ENDED)
->addViolation();
} elseif (null !== $projectBegin && $timesheetEnd->getTimestamp() < $projectBegin->getTimestamp()) {
$context->buildViolation('The project has not started at that time.')
->atPath('end')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::PROJECT_NOT_STARTED)
->addViolation();
}
}
}
} }
} }

View File

@@ -70,8 +70,8 @@ class ActivityVoter extends AbstractVoter
return true; return true;
} }
$project = $subject->getProject(); // new and global activities have no project
if (null === $project) { if (null === ($project = $subject->getProject())) {
return false; return false;
} }

View File

@@ -90,8 +90,13 @@ class ProjectVoter extends AbstractVoter
} }
} }
// new projects have no customer
if (null === ($customer = $subject->getCustomer())) {
return false;
}
/** @var Team $team */ /** @var Team $team */
foreach ($subject->getCustomer()->getTeams() as $team) { foreach ($customer->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true; return true;
} }

View File

@@ -53,9 +53,6 @@
"ref": "44d3aa7752dd46f77ba11af2297a25e1dedfb4d0" "ref": "44d3aa7752dd46f77ba11af2297a25e1dedfb4d0"
} }
}, },
"doctrine/doctrine-cache-bundle": {
"version": "1.3.2"
},
"doctrine/doctrine-fixtures-bundle": { "doctrine/doctrine-fixtures-bundle": {
"version": "3.0", "version": "3.0",
"recipe": { "recipe": {
@@ -678,9 +675,6 @@
"symfony/templating": { "symfony/templating": {
"version": "v4.1.1" "version": "v4.1.1"
}, },
"symfony/thanks": {
"version": "v1.0.7"
},
"symfony/translation": { "symfony/translation": {
"version": "3.3", "version": "3.3",
"recipe": { "recipe": {
@@ -723,15 +717,6 @@
"ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6" "ref": "6bdfa1a95f6b2e677ab985cd1af2eae35d62e0f6"
} }
}, },
"symfony/web-server-bundle": {
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "3.3",
"ref": "dae9b39fd6717970be7601101ce5aa960bf53d9a"
}
},
"symfony/webpack-encore-bundle": { "symfony/webpack-encore-bundle": {
"version": "1.0", "version": "1.0",
"recipe": { "recipe": {

View File

@@ -7,6 +7,7 @@
{% set columns = { {% set columns = {
'date': 'alwaysVisible', 'date': 'alwaysVisible',
'user': 'hidden-xs hidden-sm', 'user': 'hidden-xs hidden-sm',
'project': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm', 'description': 'hidden-xs hidden-sm',
'unit_price': 'hidden-xs text-center', 'unit_price': 'hidden-xs text-center',
'amount': 'text-center', 'amount': 'text-center',
@@ -64,7 +65,7 @@
{{ tables.data_table_header(tableName, columns) }} {{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %} {% for entry in entries %}
{% set amount = entry.amount %} {% set amount = entry.amount %}
{% set duration = entry.duration|duration_decimal ~ ' / ' ~ entry.duration|duration %} {% set duration = entry.duration|duration %}
{% set rate = 0 %} {% set rate = 0 %}
{% if entry.fixedRate is not null %} {% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %} {% set rate = entry.fixedRate %}
@@ -74,11 +75,18 @@
<tr> <tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td> <td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }}
{% if entry.activity is not null %}
<br>
<small>
{{ widgets.label_activity(entry.activity) }}
</small>
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description"> <td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if entry.description is not empty %} {% if entry.description is not empty %}
{{ entry.description|escape|desc2html }} {{ entry.description|escape|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-center">{{ rate|money(model.calculator.currency) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-center">{{ rate|money(model.calculator.currency) }}</td>

View File

@@ -87,7 +87,7 @@
{% if entry.description is not empty %} {% if entry.description is not empty %}
{{ entry.description|nl2br }} {{ entry.description|nl2br }}
{% else %} {% else %}
{{ entry.activity.name }} / {{ entry.project.name }} {% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td> <td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td>

View File

@@ -83,7 +83,7 @@
{% if entry.description is not empty %} {% if entry.description is not empty %}
{{ entry.description|nl2br }} {{ entry.description|nl2br }}
{% else %} {% else %}
{{ entry.activity.name }} / {{ entry.project.name }} {% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td class="text-right text-nowrap">{{ rate|money(model.calculator.currency) }}</td> <td class="text-right text-nowrap">{{ rate|money(model.calculator.currency) }}</td>

View File

@@ -76,7 +76,7 @@
{% if entry.description is not empty %} {% if entry.description is not empty %}
{{ entry.description|nl2br }} {{ entry.description|nl2br }}
{% else %} {% else %}
{{ entry.activity.name }} / {{ entry.project.name }} {% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td>{{ entry.duration|duration }}</td> <td>{{ entry.duration|duration }}</td>

View File

@@ -67,7 +67,7 @@
{{ macro.label(team.name, class|default('primary')) }} {{ macro.label(team.name, class|default('primary')) }}
{% endmacro %} {% endmacro %}
{% macro user_avatar(user, tooltip) %} {% macro user_avatar(user, tooltip, class) %}
{% set avatar = avatar(user, admin_lte_context.default_avatar) %} {% set avatar = avatar(user, admin_lte_context.default_avatar) %}
{% set showTooltip = tooltip|default(true) %} {% set showTooltip = tooltip|default(true) %}
{% if tooltip is not defined or tooltip is same as (false)%} {% if tooltip is not defined or tooltip is same as (false)%}

View File

@@ -32,6 +32,14 @@
{{ form_row(form.orderDate) }} {{ form_row(form.orderDate) }}
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.start) }}
</div>
<div class="col-md-6">
{{ form_row(form.end) }}
</div>
</div>
<fieldset> <fieldset>
{#<legend>Financial data</legend>#} {#<legend>Financial data</legend>#}
<div class="row"> <div class="row">

View File

@@ -1,17 +1,7 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} {% extends 'timesheet/layout-edit.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "timesheet-team/actions.html.twig" as actions %} {% import "timesheet-team/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %} {% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.timesheet_team(timesheet, 'edit') }}{% endblock %} {% block page_actions %}{{ actions.timesheet_team(timesheet, 'edit') }}{% endblock %}
{% block main %} {% set route_back = 'admin_timesheet' %}
{% if timesheet.exported %}
{{ widgets.alert('warning', ('timesheet.locked.warning'|trans({}, 'flashmessages')), ('warning'|trans({}, 'flashmessages')), 'warning') }}
{% endif %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans),
'form': form,
'back': path('admin_timesheet')
}) }}
{% endblock %}

View File

@@ -1,188 +1,7 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} {% extends 'timesheet/layout-edit.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "timesheet/actions.html.twig" as actions %} {% import "timesheet/actions.html.twig" as actions %}
{% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %} {% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.timesheet(timesheet, 'edit') }}{% endblock %} {% block page_actions %}{{ actions.timesheet(timesheet, 'edit') }}{% endblock %}
{% block main %} {% set route_back = 'timesheet' %}
{% if timesheet.exported %}
{{ widgets.alert('warning', ('timesheet.locked.warning'|trans({}, 'flashmessages')), ('warning'|trans({}, 'flashmessages')), 'warning') }}
{% endif %}
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans),
'form': form,
'back': path('timesheet')
} %}
{% embed formEditTemplate with formOptions %}
{% set length = 6 %}
{% if form.begin is defined and form.end is defined and form.duration is defined %}
{% set length = 5 %}
{% elseif form.begin is defined and form.end is defined and form.duration is not defined %}
{% set length = 6 %}
{% elseif form.begin is defined and form.end is not defined and form.duration is defined %}
{% set length = 10 %}
{% elseif form.begin is not defined and form.end is defined and form.duration is defined %}
{% set length = 10 %}
{% endif %}
{% block form_body %}
{% if form.begin is defined or form.end is defined or form.duration is defined %}
<div class="row">
{% if form.begin is defined %}
<div class="col-md-{{ length }}">
{{ form_row(form.begin) }}
</div>
{% endif %}
{% if form.end is defined %}
<div class="col-md-{{ length }}">
{{ form_row(form.end) }}
</div>
{% endif %}
{% if form.duration is defined %}
<div class="col-md-2">
{{ form_row(form.duration) }}
</div>
{% endif %}
</div>
{% endif %}
{{ form_widget(form) }}
{% endblock %}
{% block form_after %}
{% if form.begin is defined and form.end is defined and form.duration is defined %}
<script type="text/javascript">
$('body').on('blur change', '#timesheet_edit_form_begin', function(ev) {
changedBegin($(this).val());
});
$('body').on('blur change', '#timesheet_edit_form_end', function(ev) {
changedEnd($(this).val());
});
$('body').on('blur change', '#timesheet_edit_form_duration', function(ev) {
changedDuration($(this).val());
});
{#
Ruleset:
- invalid begin => skip
- empty end => set end to begin (only if duration > 0 = running record)
- invalid end => skip
- calculate duration
#}
function changedBegin(value)
{
var endField = document.getElementById('timesheet_edit_form_end');
var durationField = document.getElementById('timesheet_edit_form_duration');
var format = endField.dataset.format;
var momentDuration = moment.duration(durationField.value);
var momentBegin = moment(value, format);
if (!momentBegin.isValid()) {
return;
}
if (endField.value === '' && momentDuration.asSeconds() > 0) {
endField.value = value;
}
var momentEnd = moment(endField.value, format);
if (!momentEnd.isValid()) {
return;
}
if (momentEnd.isBefore(momentBegin)) {
endField.value = momentBegin.add(momentDuration).format(format);
}
momentBegin = moment(value, format);
momentEnd = moment(endField.value, format);
var durationMoment = moment.duration(momentEnd.diff(momentBegin));
var hours = Math.floor(durationMoment.asHours());
if (hours < 10) {
hours = '0' + hours;
}
durationField.value = hours + ':' + ('0' + durationMoment.minutes()).slice(-2);
}
{#
Ruleset:
- invalid end => skip
- empty begin => set begin to end
- invalid begin => skip
- calculate duration
#}
function changedEnd(value)
{
var beginField = document.getElementById('timesheet_edit_form_begin');
var durationField = document.getElementById('timesheet_edit_form_duration');
var format = beginField.dataset.format;
var momentDuration = moment.duration(durationField.value);
var momentEnd = moment(value, format);
if (!momentEnd.isValid()) {
return;
}
if (beginField.value === '') {
beginField.value = value;
}
var momentBegin = moment(beginField.value, format);
if (!momentBegin.isValid()) {
return;
}
if (momentEnd.isBefore(momentBegin)) {
beginField.value = momentEnd.subtract(momentDuration).format(format);
}
momentBegin = moment(beginField.value, format);
momentEnd = moment(value, format);
var durationMoment = moment.duration(momentEnd.diff(momentBegin));
var hours = Math.floor(durationMoment.asHours());
if (hours < 10) {
hours = '0' + hours;
}
durationField.value = hours + ':' + ('0' + durationMoment.minutes()).slice(-2);
}
{#
Ruleset:
- invalid duration => skip
- if begin and end are empty: set begin to now and end to duration
- if begin is empty and end is not empty: set begin to end minus duration
- if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
#}
function changedDuration(value)
{
var momentDuration = moment.duration(value);
if (!momentDuration.isValid()) {
return;
}
var beginField = document.getElementById('timesheet_edit_form_begin');
var endField = document.getElementById('timesheet_edit_form_end');
var format = endField.dataset.format;
var begin = beginField.value;
var end = endField.value;
var duration = momentDuration.asSeconds();
if (begin === '' && end === '') {
beginField.value = moment().format(format);
endField.value = moment(beginField.value, format).add(duration, 'seconds').format(format);
} else if (begin === '' && end !== '') {
beginField.value = moment(end, format).subtract(duration, 'seconds').format(format);
} else if (begin !== '' && duration > 0) {
endField.value = moment(beginField.value, format).add(duration, 'seconds').format(format);
}
}
</script>
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,218 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block main %}
{% if timesheet.exported %}
{{ widgets.alert('warning', ('timesheet.locked.warning'|trans({}, 'flashmessages')), ('warning'|trans({}, 'flashmessages')), 'warning') }}
{% endif %}
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': (timesheet.id ? 'timesheet.edit'|trans : 'create'|trans),
'form': form,
'back': path(route_back)
} %}
{% embed formEditTemplate with formOptions %}
{% set length = 6 %}
{% if form.begin is defined and form.end is defined and form.duration is defined %}
{% set length = 5 %}
{% elseif form.begin is defined and form.end is defined and form.duration is not defined %}
{% set length = 6 %}
{% elseif form.begin is defined and form.end is not defined and form.duration is defined %}
{% set length = 10 %}
{% elseif form.begin is not defined and form.end is defined and form.duration is defined %}
{% set length = 10 %}
{% endif %}
{% block form_body %}
{% if form.begin is defined or form.end is defined or form.duration is defined %}
<div class="row">
{% if form.begin is defined %}
<div class="col-md-{{ length }}">
{{ form_row(form.begin) }}
</div>
{% endif %}
{% if form.end is defined %}
<div class="col-md-{{ length }}">
{{ form_row(form.end) }}
</div>
{% endif %}
{% if form.duration is defined %}
<div class="col-md-2">
{{ form_row(form.duration) }}
</div>
{% endif %}
</div>
{% endif %}
{% if form.customer is defined %}
{{ form_row(form.customer) }}
{% endif %}
{% if form.project is defined %}
{{ form_row(form.project) }}
{% endif %}
{% if form.activity is defined %}
{{ form_row(form.activity) }}
{% endif %}
{% if form.description is defined %}
{{ form_row(form.description) }}
{% endif %}
{% if form.tags is defined %}
{{ form_row(form.tags) }}
{% endif %}
{% if form.fixedRate is defined and form.hourlyRate is defined %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.fixedRate) }}
</div>
<div class="col-md-6">
{{ form_row(form.hourlyRate) }}
</div>
</div>
{% endif %}
{{ form_widget(form) }}
{% endblock %}
{% block form_after %}
{% if form.begin is defined and form.end is defined and form.duration is defined %}
<script type="text/javascript">
$('body').on('blur change', '#timesheet_edit_form_begin', function(ev) {
changedBegin($(this).val());
});
$('body').on('blur change', '#timesheet_edit_form_end', function(ev) {
changedEnd($(this).val());
});
$('body').on('blur change', '#timesheet_edit_form_duration', function(ev) {
changedDuration($(this).val());
});
{#
Ruleset:
- invalid begin => skip
- empty end => set end to begin (only if duration > 0 = running record)
- invalid end => skip
- calculate duration
#}
function changedBegin(value)
{
var endField = document.getElementById('timesheet_edit_form_end');
var durationField = document.getElementById('timesheet_edit_form_duration');
var format = endField.dataset.format;
var momentDuration = moment.duration(durationField.value);
var momentBegin = moment(value, format);
if (!momentBegin.isValid()) {
return;
}
if (endField.value === '' && momentDuration.asSeconds() > 0) {
applyDateToField(endField, momentBegin, format);
}
var momentEnd = moment(endField.value, format);
if (!momentEnd.isValid()) {
return;
}
if (momentEnd.isBefore(momentBegin)) {
applyDateToField(endField, momentBegin.add(momentDuration), format);
}
momentBegin = moment(value, format);
momentEnd = moment(endField.value, format);
var durationMoment = moment.duration(momentEnd.diff(momentBegin));
var hours = Math.floor(durationMoment.asHours());
if (hours < 10) {
hours = '0' + hours;
}
durationField.value = hours + ':' + ('0' + durationMoment.minutes()).slice(-2);
}
{#
Ruleset:
- invalid end => skip
- empty begin => set begin to end
- invalid begin => skip
- calculate duration
#}
function changedEnd(value)
{
var beginField = document.getElementById('timesheet_edit_form_begin');
var durationField = document.getElementById('timesheet_edit_form_duration');
var format = beginField.dataset.format;
var momentDuration = moment.duration(durationField.value);
var momentEnd = moment(value, format);
if (!momentEnd.isValid()) {
return;
}
if (beginField.value === '') {
applyDateToField(beginField, momentEnd, format);
}
var momentBegin = moment(beginField.value, format);
if (!momentBegin.isValid()) {
return;
}
if (momentEnd.isBefore(momentBegin)) {
applyDateToField(beginField, momentEnd.subtract(momentDuration), format);
}
momentBegin = moment(beginField.value, format);
momentEnd = moment(value, format);
var durationMoment = moment.duration(momentEnd.diff(momentBegin));
var hours = Math.floor(durationMoment.asHours());
if (hours < 10) {
hours = '0' + hours;
}
durationField.value = hours + ':' + ('0' + durationMoment.minutes()).slice(-2);
}
{#
Ruleset:
- invalid duration => skip
- if begin and end are empty: set begin to now and end to duration
- if begin is empty and end is not empty: set begin to end minus duration
- if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
#}
function changedDuration(value)
{
var momentDuration = moment.duration(value);
if (!momentDuration.isValid()) {
return;
}
var beginField = document.getElementById('timesheet_edit_form_begin');
var endField = document.getElementById('timesheet_edit_form_end');
var format = endField.dataset.format;
var begin = beginField.value;
var end = endField.value;
var duration = momentDuration.asSeconds();
if (begin === '' && end === '') {
applyDateToField(beginField, moment(), format);
applyDateToField(endField, moment(beginField.value, format).add(duration, 'seconds'), format);
} else if (begin === '' && end !== '') {
applyDateToField(beginField, moment(end, format).subtract(duration, 'seconds'), format);
} else if (begin !== '' && duration > 0) {
applyDateToField(endField, moment(beginField.value, format).add(duration, 'seconds'), format);
}
}
function applyDateToField(field, momentObj, format)
{
field.value = momentObj.format(format);
if (jQuery(field).data('daterangepicker') !== undefined) {
jQuery(field).data('daterangepicker').setStartDate(momentObj);
jQuery(field).data('daterangepicker').setEndDate(momentObj);
}
}
</script>
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -3,8 +3,12 @@
{% import "macros/datatables.html.twig" as tables %} {% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% set showSummary = showSummary|default(false) %} {% if not showSummary is defined %}
{% set showStartEndTime = showStartEndTime|default(true) %} {% set showSummary = false %}
{% endif %}
{% if not showStartEndTime is defined %}
{% set showStartEndTime = true %}
{% endif %}
{% set columns = { {% set columns = {
'id': {'class': 'alwaysVisible multiCheckbox', 'orderBy': false, 'title': false, 'html_before': tables.datatable_multiupdate_all()}, 'id': {'class': 'alwaysVisible multiCheckbox', 'orderBy': false, 'title': false, 'html_before': tables.datatable_multiupdate_all()},

View File

@@ -12,7 +12,7 @@ namespace App\Tests\API;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\User; use App\Entity\User;
use App\Repository\Query\VisibilityQuery; use App\Repository\Query\VisibilityInterface;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock; use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Symfony\Bundle\FrameworkBundle\Client; use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -98,14 +98,14 @@ class ProjectControllerTest extends APIControllerBaseTest
// if you wonder why: SQLite does case-sensitive ordering, so "Title" > "fifth” // if you wonder why: SQLite does case-sensitive ordering, so "Title" > "fifth”
yield ['/api/projects', [], [[true, 1], [false, 1], [false, 3]]]; yield ['/api/projects', [], [[true, 1], [false, 1], [false, 3]]];
yield ['/api/projects', ['customer' => '1'], [[true, 1], [false, 1]]]; yield ['/api/projects', ['customer' => '1'], [[true, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_VISIBLE], [[true, 1], [false, 1]]]; yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityInterface::SHOW_VISIBLE], [[true, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]]; yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityInterface::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityQuery::SHOW_HIDDEN], [[false, 1]]]; yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityInterface::SHOW_HIDDEN], [[false, 1]]];
// customer is invisible, so nothing should be returned // customer is invisible, so nothing should be returned
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_VISIBLE], []]; yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityInterface::SHOW_VISIBLE], []];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_BOTH], [[false, 2], [false, 2]]]; yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
// customer is invisible, so nothing should be returned // customer is invisible, so nothing should be returned
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_HIDDEN], []]; yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityInterface::SHOW_HIDDEN, 'start' => '2010-12-11T23:59:59', 'end' => '2030-12-11T23:59:59'], []];
} }
public function testGetEntity() public function testGetEntity()
@@ -277,7 +277,7 @@ class ProjectControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle' 'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle', 'start', 'end'
]; ];
if ($full) { if ($full) {

View File

@@ -11,7 +11,6 @@ namespace App\Tests\DataFixtures;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
@@ -117,12 +116,12 @@ final class ActivityFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Project[] * @return array<int|string, Project>
*/ */
protected function getAllProjects(ObjectManager $manager) protected function getAllProjects(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var User[] $entries */ /** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll(); $entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -10,7 +10,6 @@
namespace App\Tests\DataFixtures; namespace App\Tests\DataFixtures;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
@@ -97,20 +96,4 @@ final class CustomerFixtures extends Fixture
$manager->flush(); $manager->flush();
} }
/**
* @param ObjectManager $manager
* @return Customer[]
*/
protected function getAllCustomers(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
$entries = $manager->getRepository(Customer::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
} }

View File

@@ -11,7 +11,6 @@ namespace App\Tests\DataFixtures;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
@@ -99,12 +98,12 @@ final class ProjectFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Customer[] * @return array<int|string, Customer>
*/ */
protected function getAllCustomers(ObjectManager $manager) protected function getAllCustomers(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var User[] $entries */ /** @var Customer[] $entries */
$entries = $manager->getRepository(Customer::class)->findAll(); $entries = $manager->getRepository(Customer::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -138,12 +138,12 @@ final class TeamFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Customer[] * @return array<int|string, Customer>
*/ */
private function getAllCustomers(ObjectManager $manager) private function getAllCustomers(ObjectManager $manager)
{ {
$all = []; $all = [];
/* @var Customer[] $entries */ /** @var Customer[] $entries */
$entries = $manager->getRepository(Customer::class)->findAll(); $entries = $manager->getRepository(Customer::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -154,12 +154,12 @@ final class TeamFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return User[] * @return array<int|string, User>
*/ */
private function getAllUsers(ObjectManager $manager) private function getAllUsers(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var User[] $entries */ /** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll(); $entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -297,12 +297,12 @@ final class TimesheetFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Activity[] * @return array<int|string, Activity>
*/ */
protected function getAllActivities(ObjectManager $manager) protected function getAllActivities(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Activity[] $entries */ /** @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll(); $entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;
@@ -313,12 +313,12 @@ final class TimesheetFixtures extends Fixture
/** /**
* @param ObjectManager $manager * @param ObjectManager $manager
* @return Project[] * @return array<int|string, Project>
*/ */
protected function getAllProjects(ObjectManager $manager) protected function getAllProjects(ObjectManager $manager): array
{ {
$all = []; $all = [];
/* @var Project[] $entries */ /** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll(); $entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) { foreach ($entries as $temp) {
$all[$temp->getId()] = $temp; $all[$temp->getId()] = $temp;

View File

@@ -29,6 +29,8 @@ class ProjectTest extends TestCase
self::assertNull($sut->getName()); self::assertNull($sut->getName());
self::assertNull($sut->getOrderNumber()); self::assertNull($sut->getOrderNumber());
self::assertNull($sut->getOrderDate()); self::assertNull($sut->getOrderDate());
self::assertNull($sut->getStart());
self::assertNull($sut->getEnd());
self::assertNull($sut->getComment()); self::assertNull($sut->getComment());
self::assertTrue($sut->getVisible()); self::assertTrue($sut->getVisible());
self::assertTrue($sut->isVisible()); self::assertTrue($sut->isVisible());
@@ -64,6 +66,16 @@ class ProjectTest extends TestCase
self::assertInstanceOf(Project::class, $sut->setOrderDate(null)); self::assertInstanceOf(Project::class, $sut->setOrderDate(null));
self::assertNull($sut->getOrderDate()); self::assertNull($sut->getOrderDate());
self::assertInstanceOf(Project::class, $sut->setStart($dateTime));
self::assertSame($dateTime, $sut->getStart());
self::assertInstanceOf(Project::class, $sut->setStart(null));
self::assertNull($sut->getStart());
self::assertInstanceOf(Project::class, $sut->setEnd($dateTime));
self::assertSame($dateTime, $sut->getEnd());
self::assertInstanceOf(Project::class, $sut->setEnd(null));
self::assertNull($sut->getEnd());
self::assertInstanceOf(Project::class, $sut->setComment('a comment')); self::assertInstanceOf(Project::class, $sut->setComment('a comment'));
self::assertEquals('a comment', $sut->getComment()); self::assertEquals('a comment', $sut->getComment());

View File

@@ -18,7 +18,6 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse;
* @covers \App\Export\Base\RendererTrait * @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\CsvRenderer * @covers \App\Export\Renderer\CsvRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer * @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration * @group integration
*/ */
class CsvRendererTest extends AbstractRendererTest class CsvRendererTest extends AbstractRendererTest

View File

@@ -18,7 +18,6 @@ use Twig\Environment;
* @covers \App\Export\Base\HtmlRenderer * @covers \App\Export\Base\HtmlRenderer
* @covers \App\Export\Base\RendererTrait * @covers \App\Export\Base\RendererTrait
* @covers \App\Export\Renderer\HtmlRenderer * @covers \App\Export\Renderer\HtmlRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration * @group integration
*/ */
class HtmlRendererTest extends AbstractRendererTest class HtmlRendererTest extends AbstractRendererTest

Some files were not shown because too many files have changed in this diff Show More