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
future-release=1.6
future-release=1.7
exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request,translation i18n,technical debt
issues-wo-labels=false

4
.gitignore vendored
View File

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

View File

@@ -1,23 +1,45 @@
# 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)
[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)
[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)
[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)
[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)
[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)
[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)
[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
- 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)
- A modern browser
- [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)
New feature requires the timezone date within MySQL/MariaDB.
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/).
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
### 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)

View File

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

View File

@@ -11,6 +11,7 @@
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
import moment from 'moment';
export default class KimaiSelectDataAPI extends KimaiPlugin {
@@ -32,30 +33,78 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
const API = this.getContainer().getPlugin('api');
jQuery('body').on('change', selector, function(event) {
let apiUrl = jQuery(this).attr('data-api-url').replace('-s-', jQuery(this).val());
const targetSelect = '#' + jQuery(this).attr('data-related-select');
const targetSelect = '#' + this.dataset['relatedSelect'];
// if the related target select does not exist, we do not need to load the related data
if (jQuery(targetSelect).length === 0) {
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).attr('data-empty-url') === undefined) {
if (this.dataset['emptyUrl'] === undefined) {
self._updateSelect(targetSelect, {});
jQuery(targetSelect).attr('disabled', 'disabled');
return;
}
apiUrl = jQuery(this).attr('data-empty-url').replace('-s-', jQuery(this).val());
newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
}
jQuery(targetSelect).removeAttr('disabled');
API.get(apiUrl, {}, function(data){
API.get(newApiUrl, {}, function(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) {
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,
.box > .loading-img,
.overlay-wrapper > .loading-img {
position: absolute;
position: fixed; /* see #1330 */
top: 0;
left: 0;
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/maker-bundle": "^1.12",
"symfony/phpunit-bridge": "^4.0",
"symfony/profiler-pack": "^1.0",
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0"
"symfony/profiler-pack": "^1.0"
},
"repositories": [
{

1123
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,6 @@
return [
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\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],

View File

@@ -1,6 +1,6 @@
App\Entity\Activity:
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:
id:
include: true

View File

@@ -1,6 +1,6 @@
App\Entity\Project:
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:
id:
include: true
@@ -23,6 +23,12 @@ App\Entity\Project:
orderDate:
include: true
groups: [Entity]
start:
include: true
groups: [Project]
end:
include: true
groups: [Project]
fixedRate:
include: true
groups: [Project]

View File

@@ -39,6 +39,7 @@
"sass-loader": "^7.0.2",
"select2": "^4.0.10",
"select2-bootstrap-theme": "^0.1.0-beta.10",
"sortablejs": "^1.10.1",
"sweetalert2": "^9.3",
"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/0.1a6ffb43.js",
"build/1.16767f92.js",
"build/app.23b99cbb.js"
"build/app.055e71d1.js"
],
"css": [
"build/app.ce77694e.css"
"build/app.483d7949.css"
]
},
"chart": {
@@ -35,8 +35,8 @@
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.23b99cbb.js": "sha384-OcCvUr52EqOwqv3QvQVwP6y4AOv3JRpxwp629Df5QnPAkZqHzX5NwbaZJQZ0Xzfl",
"build/app.ce77694e.css": "sha384-xUdH11jkeXQ1YfJ66YW+tlcQt23/PKuPZmMi4XJuVuMLdonUk0qKAJ0HJrRL5hvf",
"build/app.055e71d1.js": "sha384-NGXBt+8vQ5h4orQ5B82W9GoeVpu4bNmiJI7k/goKm1ogp2JGvo0tyadJdoOf3Dt+",
"build/app.483d7949.css": "sha384-u8/TZyq87MTQTKScxUGmbJcWEBhsoXAlsUTB8XSgKvuufZ5bODWYm1o1E1DRosJu",
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1",

View File

@@ -2,8 +2,8 @@
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
"build/1.16767f92.js": "build/1.16767f92.js",
"build/2.dad56560.js": "build/2.dad56560.js",
"build/app.css": "build/app.ce77694e.css",
"build/app.js": "build/app.23b99cbb.js",
"build/app.css": "build/app.483d7949.css",
"build/app.js": "build/app.055e71d1.js",
"build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.9e7cdb6a.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\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\SearchTerm;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
@@ -29,6 +30,7 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\Validator\Constraints;
/**
* @RouteResource("Project")
@@ -49,16 +51,21 @@ class ProjectController extends BaseApiController
* @var EventDispatcherInterface
*/
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->repository = $repository;
$this->dispatcher = $dispatcher;
$this->dateTime = $dateTime;
}
/**
* Returns a collection of projects
* Returns a collection of projects.
*
* @SWG\Response(
* 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="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="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")
@@ -98,6 +108,27 @@ class ProjectController extends BaseApiController
$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'))) {
$query->setSearchTerm(new SearchTerm($term));
}

View File

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

View File

@@ -10,7 +10,7 @@
namespace App\Command;
use App\Entity\User;
use Symfony\Bridge\Doctrine\RegistryInterface;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
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\Validator\Validator\ValidatorInterface;
/**
* Command used to create application user.
*/
class CreateUserCommand extends Command
final class CreateUserCommand extends Command
{
/**
* @var UserPasswordEncoderInterface
*/
protected $encoder;
private $encoder;
/**
* @var RegistryInterface
* @var ManagerRegistry
*/
protected $doctrine;
private $doctrine;
/**
* @var ValidatorInterface
*/
protected $validator;
private $validator;
/**
* @param UserPasswordEncoderInterface $encoder
* @param RegistryInterface $registry
* @param ValidatorInterface $validator
*/
public function __construct(
UserPasswordEncoderInterface $encoder,
RegistryInterface $registry,
ValidatorInterface $validator
) {
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
{
$this->encoder = $encoder;
$this->doctrine = $registry;
$this->validator = $validator;
@@ -130,14 +119,14 @@ class CreateUserCommand extends Command
$entityManager->persist($user);
$entityManager->flush();
$io->success('Success! Created user: ' . $user->getUsername());
return 0;
} catch (\Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername());
$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)) {
$tmpFallback = $this->customers->find($fallback);
} else {
/** @var Customer|null $tmpFallback */
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
}
}

View File

@@ -11,25 +11,34 @@ namespace App\Command;
use App\Doctrine\TimesheetSubscriber;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Project;
use App\Entity\ProjectMeta;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Timesheet\Util;
use Doctrine\Common\Persistence\ObjectManager;
use DateTime;
use DateTimeZone;
use Doctrine\DBAL\Configuration;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Types\DateTimeType;
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\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\Validator\ConstraintViolation;
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:
* @codeCoverageIgnore
*/
class KimaiImporterCommand extends Command
final class KimaiImporterCommand extends Command
{
// minimum required Kimai and database version, lower versions are not supported by this command
public const MIN_VERSION = '1.0.1';
@@ -57,7 +66,7 @@ class KimaiImporterCommand extends Command
protected $validator;
/**
* Connection to the Kimai v2 database to write imported data to
* @var RegistryInterface
* @var ManagerRegistry
*/
protected $doctrine;
/**
@@ -96,16 +105,8 @@ class KimaiImporterCommand extends Command
*/
protected $oldActivities = [];
/**
* @param UserPasswordEncoderInterface $encoder
* @param RegistryInterface $registry
* @param ValidatorInterface $validator
*/
public function __construct(
UserPasswordEncoderInterface $encoder,
RegistryInterface $registry,
ValidatorInterface $validator
) {
public function __construct(UserPasswordEncoderInterface $encoder, ManagerRegistry $registry, ValidatorInterface $validator)
{
$this->encoder = $encoder;
$this->doctrine = $registry;
$this->validator = $validator;
@@ -125,12 +126,14 @@ class KimaiImporterCommand extends Command
->addArgument(
'connection',
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('password', InputArgument::REQUIRED, 'The new password for all imported user')
->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')
->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)
{
// 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
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
@@ -157,25 +160,25 @@ class KimaiImporterCommand extends Command
if (trim(strlen($password)) < 6) {
$io->error('Password length is not sufficient, at least 6 character are required');
return;
return 1;
}
$country = $input->getArgument('country');
if (2 != trim(strlen($country))) {
$io->error('Country code needs to be exactly 2 character');
return;
return 1;
}
$currency = $input->getArgument('currency');
if (3 != trim(strlen($currency))) {
$io->error('Currency code needs to be exactly 3 character');
return;
return 1;
}
if (!$this->checkDatabaseVersion($io, self::MIN_VERSION, self::MIN_REVISION)) {
return;
return 1;
}
$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
try {
$users = $this->fetchAllFromImport('users');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load users: ' . $ex->getMessage());
return;
return 1;
}
try {
$customer = $this->fetchAllFromImport('customers');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load customers: ' . $ex->getMessage());
return;
return 1;
}
try {
$projects = $this->fetchAllFromImport('projects');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load projects: ' . $ex->getMessage());
return;
return 1;
}
try {
$activities = $this->fetchAllFromImport('activities');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load activities: ' . $ex->getMessage());
return;
return 1;
}
try {
$activityToProject = $this->fetchAllFromImport('projects_activities');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load activities-project mapping: ' . $ex->getMessage());
return;
return 1;
}
try {
$records = $this->fetchAllFromImport('timeSheet');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load timeSheet: ' . $ex->getMessage());
return;
return 1;
}
try {
$fixedRates = $this->fetchAllFromImport('fixedRates');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load fixedRates: ' . $ex->getMessage());
return;
return 1;
}
try {
$rates = $this->fetchAllFromImport('rates');
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to load rates: ' . $ex->getMessage());
return;
return 1;
}
$bytesCached = memory_get_usage(true);
@@ -252,57 +255,55 @@ class KimaiImporterCommand extends Command
$allImports = 0;
try {
$counter = $this->importUsers($io, $password, $users, $rates);
$counter = $this->importUsers($io, $password, $users, $rates, $input->getOption('timezone'), $input->getOption('language'));
$allImports += $counter;
$io->success('Imported users: ' . $counter);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
return 1;
}
try {
$counter = $this->importCustomers($io, $customer, $country, $currency);
$allImports += $counter;
$io->success('Imported customers: ' . $counter);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
return 1;
}
try {
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported projects: ' . $counter);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
return 1;
}
try {
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported activities: ' . $counter);
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
return 1;
}
try {
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
$allImports += $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());
return;
return 1;
}
// TODO support expenses - new database required
$bytesImported = memory_get_usage(true);
$io->success(
@@ -313,6 +314,8 @@ class KimaiImporterCommand extends Command
'Total consumption for importing ' . $allImports . ' new database entries: ' .
$this->bytesHumanReadable($bytesImported - $bytesStart)
);
return 0;
}
/**
@@ -323,7 +326,6 @@ class KimaiImporterCommand extends Command
* @param string $requiredVersion
* @param string $requiredRevision
* @return bool
* @throws \Doctrine\DBAL\DBALException
*/
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{
@@ -419,7 +421,7 @@ class KimaiImporterCommand extends Command
}
/**
* @return RegistryInterface
* @return ManagerRegistry
*/
protected function getDoctrine()
{
@@ -436,7 +438,7 @@ class KimaiImporterCommand extends Command
$errors = $this->validator->validate($object);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
/** @var ConstraintViolation $error */
foreach ($errors as $error) {
$io->error(
(string) $error
@@ -476,10 +478,12 @@ class KimaiImporterCommand extends Command
* @param string $password
* @param array $users
* @param array $rates
* @param string $timezone
* @param string $language
* @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;
$entityManager = $this->getDoctrine()->getManager();
@@ -501,7 +505,7 @@ class KimaiImporterCommand extends Command
$user->setPassword($pwd);
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
@@ -525,6 +529,14 @@ class KimaiImporterCommand extends Command
$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
foreach ($rates as $ratesRow) {
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());
}
++$counter;
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage());
}
@@ -584,7 +596,7 @@ class KimaiImporterCommand extends Command
* @param string $country
* @param string $currency
* @return int
* @throws \Exception
* @throws Exception
*/
protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency)
{
@@ -617,8 +629,15 @@ class KimaiImporterCommand extends Command
->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)) {
throw new \Exception('Failed to validate customer: ' . $customer->getName());
throw new Exception('Failed to validate customer: ' . $customer->getName());
}
try {
@@ -628,7 +647,7 @@ class KimaiImporterCommand extends Command
$io->success('Created customer: ' . $customer->getName());
}
++$counter;
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Reason: ' . $ex->getMessage());
$io->error('Failed to create customer: ' . $customer->getName());
}
@@ -659,7 +678,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
* @throws Exception
*/
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
{
@@ -684,6 +703,13 @@ class KimaiImporterCommand extends Command
->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) {
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
continue;
@@ -703,7 +729,7 @@ class KimaiImporterCommand extends Command
}
if (!$this->validateImport($io, $project)) {
throw new \Exception('Failed to validate project: ' . $project->getName());
throw new Exception('Failed to validate project: ' . $project->getName());
}
try {
@@ -713,7 +739,7 @@ class KimaiImporterCommand extends Command
$io->success('Created project: ' . $project->getName() . ' for customer: ' . $customer->getName());
}
++$counter;
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create project: ' . $project->getName());
$io->error('Reason: ' . $ex->getMessage());
}
@@ -748,7 +774,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
* @throws Exception
*/
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) {
if (!isset($this->projects[$projectId])) {
throw new \Exception(
throw new Exception(
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
);
}
@@ -802,7 +828,7 @@ class KimaiImporterCommand extends Command
* @param array $rates
* @param int $projectId
* @return Activity
* @throws \Exception
* @throws Exception
*/
protected function createActivity(
SymfonyStyle $io,
@@ -826,7 +852,7 @@ class KimaiImporterCommand extends Command
}
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)
);
}
@@ -844,6 +870,13 @@ class KimaiImporterCommand extends Command
$activity->setProject($project);
}
$metaField = new ActivityMeta();
$metaField->setName('_imported_id');
$metaField->setValue($oldActivity['activityID']);
$metaField->setIsVisible(false);
$activity->setMetaField($metaField);
foreach ($fixedRates as $fixedRow) {
if ($fixedRow['activityID'] === null) {
continue;
@@ -871,7 +904,7 @@ class KimaiImporterCommand extends Command
}
if (!$this->validateImport($io, $activity)) {
throw new \Exception('Failed to validate activity: ' . $activity->getName());
throw new Exception('Failed to validate activity: ' . $activity->getName());
}
try {
@@ -880,7 +913,7 @@ class KimaiImporterCommand extends Command
if ($this->debug) {
$io->success('Created activity: ' . $activity->getName());
}
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create activity: ' . $activity->getName());
$io->error('Reason: ' . $ex->getMessage());
}
@@ -921,7 +954,7 @@ class KimaiImporterCommand extends Command
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
* @throws Exception
*/
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());
}
$userCounter++;
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage());
$failed++;
@@ -1043,11 +1076,11 @@ class KimaiImporterCommand extends Command
$user = $this->users[$oldRecord['userID']];
$timezone = $user->getTimezone();
$dateTimezone = new \DateTimeZone('UTC');
$dateTimezone = new DateTimeZone('UTC');
$begin = new \DateTime('@' . $oldRecord['start']);
$begin = new DateTime('@' . $oldRecord['start']);
$begin->setTimezone($dateTimezone);
$end = new \DateTime('@' . $oldRecord['end']);
$end = new DateTime('@' . $oldRecord['end']);
$end->setTimezone($dateTimezone);
// ---------- workaround for localizeDates ----------
@@ -1075,30 +1108,33 @@ class KimaiImporterCommand extends Command
;
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++;
continue;
}
try {
$entityManager->persist($timesheet);
$entityManager->flush();
if ($this->debug) {
$io->success('Created timesheet record: ' . $timesheet->getId());
}
++$counter;
} catch (\Exception $ex) {
} catch (Exception $ex) {
$io->error('Failed to create timesheet record: ' . $ex->getMessage());
$failed++;
}
$io->write('.');
if (0 == $counter % 80) {
$io->writeln(' (' . $counter . '/' . $total . ')');
$entityManager->flush();
$entityManager->clear(Timesheet::class);
$io->writeln(' (' . $counter . '/' . $total . ')');
}
}
$entityManager->flush();
$entityManager->clear(Timesheet::class);
for ($i = 0; $i < 80 - ($counter % 80); $i++) {
$io->write(' ');
}

View File

@@ -68,7 +68,7 @@ class VersionCommand extends Command
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;
}

View File

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

View File

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

View File

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

View File

@@ -47,12 +47,12 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return User[]
* @return array<int|string, User>
*/
protected function getAllUsers(ObjectManager $manager)
protected function getAllUsers(ObjectManager $manager): array
{
$all = [];
/* @var User[] $entries */
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -63,13 +63,13 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return Project[]
* @return array<int|string, Project>
*/
protected function getAllProjects(ObjectManager $manager)
protected function getAllProjects(ObjectManager $manager): array
{
$all = [];
/* @var Project[] $entries */
/** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $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 MAX_TIMESHEETS_PER_USER = 500;
public const MAX_TIMESHEETS_TOTAL = 5000;
public const MIN_RUNNING_TIMESHEETS_PER_USER = 0;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 3;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 2;
public const TIMERANGE_DAYS = 1095; // 3 years
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
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
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
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;
}
@@ -109,7 +108,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
}
// 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++) {
$entry = $this->createTimesheetEntry(
$user,
@@ -144,12 +143,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return Tag[]
* @return array<int|string, Tag>
*/
protected function getAllTags(ObjectManager $manager)
protected function getAllTags(ObjectManager $manager): array
{
$all = [];
/* @var Tag[] $entries */
/** @var Tag[] $entries */
$entries = $manager->getRepository(Tag::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -160,12 +159,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return User[]
* @return array<int|string, User>
*/
protected function getAllUsers(ObjectManager $manager)
protected function getAllUsers(ObjectManager $manager): array
{
$all = [];
/* @var User[] $entries */
/** @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -176,12 +175,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return Project[]
* @return array<int|string, Project>
*/
protected function getAllProjects(ObjectManager $manager)
protected function getAllProjects(ObjectManager $manager): array
{
$all = [];
/* @var Project[] $entries */
/** @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -192,12 +191,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
/**
* @param ObjectManager $manager
* @return Activity[]
* @return array<int|string, Activity>
*/
protected function getAllActivities(ObjectManager $manager)
protected function getAllActivities(ObjectManager $manager): array
{
$all = [];
/* @var Activity[] $entries */
/** @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;

View File

@@ -33,7 +33,7 @@ class AppExtension extends Extension
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'])) {
@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']) {

View File

@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* }
* )
* @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","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)
*/
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
*
@@ -192,8 +215,41 @@ class Project implements EntityWithMetaFields
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
{
$this->localizeDates();
return $this->orderDate;
}
@@ -201,6 +257,46 @@ class Project implements EntityWithMetaFields
{
$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;
}

View File

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

View File

@@ -17,6 +17,7 @@ use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use DateTime;
@@ -158,8 +159,10 @@ abstract class AbstractSpreadsheetRenderer
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$customerQuery = $query->copyTo(new CustomerQuery());
$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));
$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\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
@@ -62,8 +63,10 @@ class HtmlRenderer
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$customerQuery = $query->copyTo(new CustomerQuery());
$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));
$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
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
]);
if ($options['include_rate']) {
$builder
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
]);
}
$builder->add('entities', HiddenType::class, [
'required' => false,
@@ -241,6 +243,7 @@ class TimesheetMultiUpdate extends AbstractType
'csrf_field_name' => '_token',
'csrf_token_id' => 'timesheet_multiupdate',
'include_user' => false,
'include_rate' => false,
'include_exported' => false,
]);
}

View File

@@ -63,6 +63,14 @@ class ProjectEditForm extends AbstractType
'label' => 'label.orderDate',
'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, [
'query_builder' => function (CustomerRepository $repo) use ($builder, $customer) {
$query = new CustomerFormTypeQuery($customer);

View File

@@ -9,30 +9,20 @@
namespace App\Form;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Form\Type\ActivityType;
use App\Form\Type\CustomerType;
use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\ProjectType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
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 App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -43,6 +33,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
class TimesheetEditForm extends AbstractType
{
use FormTrait;
/**
* @var CustomerRepository
*/
@@ -141,7 +133,7 @@ class TimesheetEditForm extends AbstractType
$this->addCustomer($builder, $customer);
}
$this->addProject($builder, $customerCount, $isNew, $project, $customer);
$this->addProject($builder, $isNew, $project, $customer);
$this->addActivity($builder, $activity, $project);
$this->addDescription($builder);
$this->addTags($builder);
@@ -169,114 +161,6 @@ class TimesheetEditForm extends AbstractType
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)
{
$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)
{
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
$builder->add('customer', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder, $required) {
function (FormEvent $event) use ($builder, $options) {
$data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [
'required' => $required,
$event->getForm()->add('customer', CustomerType::class, array_merge([
'required' => false,
'project_enabled' => true,
'end_date_param' => '%daterange%',
'start_date_param' => '%daterange%',
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery();
$query->setUser($builder->getOption('user'));
@@ -92,7 +94,7 @@ abstract class AbstractToolbarForm extends AbstractType
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
$builder->add('project', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder) {
function (FormEvent $event) use ($builder, $options) {
$data = $event->getData();
$event->getForm()->add('project', ProjectType::class, [
$event->getForm()->add('project', ProjectType::class, array_merge([
'required' => false,
'activity_enabled' => true,
'query_builder' => function (ProjectRepository $repo) use ($builder, $data) {
'query_builder' => function (ProjectRepository $repo) use ($builder, $data, $options) {
$query = new ProjectFormTypeQuery();
$query->setUser($builder->getOption('user'));
@@ -152,10 +154,13 @@ abstract class AbstractToolbarForm extends AbstractType
if (isset($data['project']) && !empty($data['project'])) {
$query->setProject($data['project']);
}
if (isset($options['ignore_date']) && true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query);
},
]);
], $options));
}
);
}
@@ -175,7 +180,12 @@ abstract class AbstractToolbarForm extends AbstractType
$query = new ActivityFormTypeQuery();
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'])) {
$query->setProject($data['project']);

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,7 @@ class ProjectType extends AbstractType
'query_builder_for_user' => true,
'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
'ignore_date' => false,
]);
$resolver->setDefault('query_builder', function (Options $options) {
@@ -73,6 +74,9 @@ class ProjectType extends AbstractType
if (true === $options['query_builder_for_user']) {
$query->setUser($options['user']);
}
if (true === $options['ignore_date']) {
$query->setIgnoreDate(true);
}
return $repo->getQueryBuilderForFormType($query);
};
@@ -83,7 +87,7 @@ class ProjectType extends AbstractType
return [
'select' => 'activity',
'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']],
];
}

View File

@@ -10,9 +10,12 @@
namespace App\Form\Type;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TeamType extends AbstractType
@@ -25,13 +28,26 @@ class TeamType extends AbstractType
$resolver->setDefaults([
'class' => Team::class,
'label' => 'label.team',
'query_builder' => function (TeamRepository $repo) {
return $repo->createQueryBuilder('t')->orderBy('t.name', 'ASC');
},
'teamlead_only' => true,
'choice_label' => function (Team $team) {
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;
use App\Repository\Query\VisibilityQuery;
use App\Repository\Query\VisibilityInterface;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,9 +27,9 @@ class VisibilityType extends AbstractType
$resolver->setDefaults([
'label' => 'label.visible',
'choices' => [
'both' => VisibilityQuery::SHOW_BOTH,
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
'both' => VisibilityInterface::SHOW_BOTH,
'yes' => VisibilityInterface::SHOW_VISIBLE,
'no' => VisibilityInterface::SHOW_HIDDEN,
],
]);
}

View File

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

View File

@@ -20,6 +20,10 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
{
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()) {
throw new \Exception('Cannot handle un-persisted activities');
}

View File

@@ -364,25 +364,23 @@ final class InvoiceModel
$amount = $invoiceItem->getAmount();
}
if (empty($description)) {
$description = $invoiceItem->getActivity()->getName();
}
$activity = $invoiceItem->getActivity();
$project = $invoiceItem->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$user = $invoiceItem->getUser();
$begin = $invoiceItem->getBegin();
$end = $invoiceItem->getEnd();
if (empty($description) && null !== $activity) {
$description = $activity->getName();
}
// this should never happen!
if (empty($appliedRate)) {
$appliedRate = 0;
}
$activity = $invoiceItem->getActivity();
$project = $invoiceItem->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$begin = $invoiceItem->getBegin();
$end = $invoiceItem->getEnd();
$values = [
'entry.row' => '',
'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('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('customer_visible', true, \PDO::PARAM_BOOL);
@@ -246,6 +276,47 @@ class ProjectRepository extends EntityRepository
->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());
if ($query->hasSearchTerm()) {

View File

@@ -92,6 +92,12 @@ final class ActivityFormTypeQuery
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;
/**
* @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';
/**
* @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';
/**
* @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';
@@ -63,7 +63,7 @@ class BaseQuery
private $order = self::ORDER_ASC;
/**
* @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;
/**
@@ -79,6 +79,23 @@ class BaseQuery
*/
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
{
$this->teams[$team->getId()] = $team;
@@ -189,7 +206,7 @@ class BaseQuery
*/
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;
}
@@ -254,4 +271,27 @@ class BaseQuery
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
*/
class CustomerQuery extends VisibilityQuery
class CustomerQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
public const CUSTOMER_ORDER_ALLOWED = ['id', 'name', 'comment', 'country', 'number'];
public function __construct()

View File

@@ -36,6 +36,10 @@ final class ProjectFormTypeQuery
* @var array<Team>
*/
private $teams = [];
/**
* @var bool
*/
private $ignoreDate = false;
/**
* @param Project|int|null $project
@@ -126,4 +130,16 @@ final class ProjectFormTypeQuery
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
*/
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
*/
private $customer;
/**
* @var \DateTime
*/
private $projectStart;
/**
* @var \DateTime
*/
private $projectEnd;
public function __construct()
{
parent::__construct();
$this->setDefaults([
'orderBy' => 'name',
]);
@@ -49,4 +58,28 @@ class ProjectQuery extends CustomerQuery
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
*/
class UserQuery extends VisibilityQuery
class UserQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
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.
*
* @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;
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;
}
use VisibilityTrait;
}

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();
}
/**
* 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
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -134,6 +153,11 @@ class TeamRepository extends EntityRepository
return $qb;
}
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all user
@@ -146,10 +170,21 @@ class TeamRepository extends EntityRepository
return;
}
$or = $qb->expr()->orX();
if (null !== $user) {
$qb
->andWhere('t.teamlead = :id')
->setParameter('id', $user);
$or->add($qb->expr()->eq('t.teamlead', ':id'));
$qb->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
{
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)
{
@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);
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;
}
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->setFromToFromRequest($timesheet, $request);
}

View File

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

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ final class PunchInOutMode implements TrackingModeInterface
return false;
}
public function create(Timesheet $timesheet, Request $request): void
public function create(Timesheet $timesheet, ?Request $request = null): void
{
if (null === $timesheet->getBegin()) {
$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.
* 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
{
@@ -23,9 +25,9 @@ interface TrackingModeInterface
* before form data is rendered/processed.
*
* @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.

View File

@@ -38,6 +38,7 @@ final class IconExtension extends AbstractExtension
'download' => 'fas fa-download',
'duration' => 'far fa-hourglass',
'edit' => 'far fa-edit',
'end' => 'fas fa-stopwatch',
'export' => 'fas fa-file-export',
'filter' => 'fas fa-filter',
'help' => 'far fa-question-circle',
@@ -55,6 +56,8 @@ final class IconExtension extends AbstractExtension
'off' => 'fas fa-toggle-off',
'on' => 'fas fa-toggle-on',
'pdf' => 'fas fa-file-pdf',
'pause' => 'fas fa-pause',
'pause-small' => 'far fa-pause-circle',
'permissions' => 'fas fa-user-lock',
'phone' => 'fas fa-phone',
'plugin' => 'fas fa-plug',
@@ -67,7 +70,7 @@ final class IconExtension extends AbstractExtension
'search' => 'fas fa-search',
'settings' => 'fas fa-cog',
'shop' => 'fas fa-shopping-cart',
'start' => 'fas fa-play-circle',
'start' => 'fas fa-play',
'start-small' => 'far fa-play-circle',
'stop' => 'fas fa-stop',
'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
{
public const MISSING_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d81';
public const END_BEFORE_BEGIN_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d82';
public const BEGIN_IN_FUTURE_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d83';
public const MISSING_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d84';
public const MISSING_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d86';
public const DISABLED_ACTIVITY_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d87';
public const DISABLED_PROJECT_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d88';
public const DISABLED_CUSTOMER_ERROR = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d89';
public const START_DISALLOWED = 'xd5hffg-dsfef3-426a-83d7-1f2d33hs5d90';
public const MISSING_BEGIN_ERROR = 'kimai-timesheet-81';
public const END_BEFORE_BEGIN_ERROR = 'kimai-timesheet-82';
public const BEGIN_IN_FUTURE_ERROR = 'kimai-timesheet-83';
public const MISSING_ACTIVITY_ERROR = 'kimai-timesheet-84';
public const MISSING_PROJECT_ERROR = 'kimai-timesheet-85';
public const ACTIVITY_PROJECT_MISMATCH_ERROR = 'kimai-timesheet-86';
public const DISABLED_ACTIVITY_ERROR = 'kimai-timesheet-87';
public const DISABLED_PROJECT_ERROR = 'kimai-timesheet-88';
public const DISABLED_CUSTOMER_ERROR = 'kimai-timesheet-89';
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 = [
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_CUSTOMER_ERROR => 'Cannot start a disabled customer.',
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.';

View File

@@ -166,7 +166,9 @@ class TimesheetValidator extends ConstraintValidator
->addViolation();
}
if (null === $timesheet->getEnd() && !$activity->isVisible()) {
$timesheetEnd = $timesheet->getEnd();
if (null === $timesheetEnd && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
@@ -174,7 +176,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation();
}
if (null === $timesheet->getEnd() && !$project->isVisible()) {
if (null === $timesheetEnd && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
@@ -182,12 +184,52 @@ class TimesheetValidator extends ConstraintValidator
->addViolation();
}
if (null === $timesheet->getEnd() && !$project->getCustomer()->isVisible()) {
if (null === $timesheetEnd && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::DISABLED_CUSTOMER_ERROR)
->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;
}
$project = $subject->getProject();
if (null === $project) {
// new and global activities have no project
if (null === ($project = $subject->getProject())) {
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 */
foreach ($subject->getCustomer()->getTeams() as $team) {
foreach ($customer->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}

View File

@@ -53,9 +53,6 @@
"ref": "44d3aa7752dd46f77ba11af2297a25e1dedfb4d0"
}
},
"doctrine/doctrine-cache-bundle": {
"version": "1.3.2"
},
"doctrine/doctrine-fixtures-bundle": {
"version": "3.0",
"recipe": {
@@ -678,9 +675,6 @@
"symfony/templating": {
"version": "v4.1.1"
},
"symfony/thanks": {
"version": "v1.0.7"
},
"symfony/translation": {
"version": "3.3",
"recipe": {
@@ -723,15 +717,6 @@
"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": {
"version": "1.0",
"recipe": {

View File

@@ -7,6 +7,7 @@
{% set columns = {
'date': 'alwaysVisible',
'user': 'hidden-xs hidden-sm',
'project': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'unit_price': 'hidden-xs text-center',
'amount': 'text-center',
@@ -64,7 +65,7 @@
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set amount = entry.amount %}
{% set duration = entry.duration|duration_decimal ~ ' / ' ~ entry.duration|duration %}
{% set duration = entry.duration|duration %}
{% set rate = 0 %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
@@ -74,11 +75,18 @@
<tr>
<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, '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">
{% if entry.description is not empty %}
{{ entry.description|escape|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</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 %}
{{ entry.description|nl2br }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</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 %}
{{ entry.description|nl2br }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</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 %}
{{ entry.description|nl2br }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td>{{ entry.duration|duration }}</td>

View File

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

View File

@@ -32,6 +32,14 @@
{{ form_row(form.orderDate) }}
</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>
{#<legend>Financial data</legend>#}
<div class="row">

View File

@@ -1,17 +1,7 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% extends 'timesheet/layout-edit.html.twig' %}
{% import "timesheet-team/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_timesheet.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.timesheet_team(timesheet, 'edit') }}{% endblock %}
{% block main %}
{% 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 %}
{% set route_back = 'admin_timesheet' %}

View File

@@ -1,188 +1,7 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% extends 'timesheet/layout-edit.html.twig' %}
{% import "timesheet/actions.html.twig" as actions %}
{% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.timesheet(timesheet, 'edit') }}{% endblock %}
{% 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('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 %}
{% set route_back = 'timesheet' %}

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/toolbar.html.twig" as toolbar %}
{% set showSummary = showSummary|default(false) %}
{% set showStartEndTime = showStartEndTime|default(true) %}
{% if not showSummary is defined %}
{% set showSummary = false %}
{% endif %}
{% if not showStartEndTime is defined %}
{% set showStartEndTime = true %}
{% endif %}
{% set columns = {
'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\Project;
use App\Entity\User;
use App\Repository\Query\VisibilityQuery;
use App\Repository\Query\VisibilityInterface;
use App\Tests\Mocks\ProjectTestMetaFieldSubscriberMock;
use Symfony\Bundle\FrameworkBundle\Client;
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”
yield ['/api/projects', [], [[true, 1], [false, 1], [false, 3]]];
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' => VisibilityQuery::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_VISIBLE], [[true, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityInterface::SHOW_BOTH], [[true, 1], [false, 1], [false, 1]]];
yield ['/api/projects', ['customer' => '1', 'visible' => VisibilityInterface::SHOW_HIDDEN], [[false, 1]]];
// customer is invisible, so nothing should be returned
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_VISIBLE], []];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityQuery::SHOW_BOTH], [[false, 2], [false, 2]]];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityInterface::SHOW_VISIBLE], []];
yield ['/api/projects', ['customer' => '2', 'visible' => VisibilityInterface::SHOW_BOTH], [[false, 2], [false, 2]]];
// 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()
@@ -277,7 +277,7 @@ class ProjectControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true)
{
$expectedKeys = [
'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle'
'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle', 'start', 'end'
];
if ($full) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,8 @@ class ProjectTest extends TestCase
self::assertNull($sut->getName());
self::assertNull($sut->getOrderNumber());
self::assertNull($sut->getOrderDate());
self::assertNull($sut->getStart());
self::assertNull($sut->getEnd());
self::assertNull($sut->getComment());
self::assertTrue($sut->getVisible());
self::assertTrue($sut->isVisible());
@@ -64,6 +66,16 @@ class ProjectTest extends TestCase
self::assertInstanceOf(Project::class, $sut->setOrderDate(null));
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::assertEquals('a comment', $sut->getComment());

View File

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

View File

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

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