Compare commits

...

25 Commits
0.7 ... 0.8

Author SHA1 Message Date
Kevin Papst
0b57bd51ca fix sql for only_full_group_by (#581) 2019-02-19 13:39:08 +01:00
Kevin Papst
49c78ddd9d updated CHANGELOG (#580) 2019-02-19 12:49:02 +01:00
Kevin Papst
47b7046743 release 0.8 (#567) 2019-02-19 11:03:11 +01:00
Kevin Papst
b4f1cae323 fix pagination in combination with daterange picker (#568) 2019-02-14 01:09:23 +01:00
Kevin Papst
17856e0b96 handle deleted user during import from v1 (#569) 2019-02-14 00:26:54 +01:00
Kevin Papst
8005679e60 allow to query other users timesheets via api (#563) 2019-02-14 00:04:27 +01:00
Kevin Papst
ef33233624 fix timezone problems in timesheet forms (#555) 2019-02-13 21:37:54 +01:00
Kevin Papst
2c6f57c7ce daily stats in timesheet (#552) 2019-02-13 15:36:59 +01:00
Kevin Papst
fd60d5bb17 Updated HTML invoice templates (#560) 2019-02-12 15:35:33 +01:00
Kevin Papst
742e6eb3a3 updated composer packages (#559) 2019-02-11 20:38:40 +01:00
infeeeee
331ea586f1 updated php requirements in dev-documentation (#551) 2019-02-11 16:28:27 +01:00
infeeeee
afb0eabaa4 styles: year selector width, recent activities menu width (#549) 2019-02-09 23:13:47 +01:00
Kevin Papst
afa0aa5b3a improve recent activities (#550) 2019-02-09 23:01:35 +01:00
Kevin Papst
1f99f69ecd fix daterange-picker for fr, hu and ar (#547) 2019-02-08 20:02:23 +01:00
Kevin Papst
d00596d297 convert timesheets to UTC with support for user timezone (#372) 2019-02-08 18:24:33 +01:00
Kevin Papst
8dcc18dde3 improved installation docs (#548) 2019-02-08 18:16:53 +01:00
Sanjit Patel
616d514d5a Use Symfony formatter for currency symbol placement (#542) 2019-02-08 16:56:47 +01:00
Kevin Papst
b2587c71b8 do not limit timesheet-export page size (#545) 2019-02-07 23:43:20 +01:00
Kevin Papst
816866549c added export module (#538) 2019-02-05 21:37:33 +01:00
Kevin Papst
062735c9e3 added daterange-picker and localized date-inputs (#540) 2019-02-03 21:24:30 +01:00
Kevin Papst
aae9183de6 highlight current day in date-picker (#527) 2019-02-02 15:27:02 +01:00
Kevin Papst
7f3e2d453f updated documentation (#536) 2019-02-01 16:33:55 +01:00
Kevin Papst
1b42aed34c support remote data in beta-test selectpicker (#529) 2019-01-31 12:59:25 +01:00
Kevin Papst
974806d145 Improve allowed running records (#528) 2019-01-29 20:45:04 +01:00
Kevin Papst
fe4c3c10d6 added configurable view after login (#523) 2019-01-29 02:15:12 +01:00
222 changed files with 8810 additions and 2296 deletions

View File

@@ -1,22 +0,0 @@
## Description
## Expected Behavior
## Actual Behavior
## Possible Fix
## Steps to Reproduce
1.
2.
3.
4.
## Your Environment
* Version:
* Browser Name and version:
* Operating System and version (desktop or mobile):

View File

@@ -1,6 +1,6 @@
---
name: Bug report
about: Create a report to help us improve
about: Create a report to help us improve Kimai
---
@@ -11,25 +11,24 @@ A clear and concise description of what the bug is.
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
3. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logfile**
```
Add the last lines from your logfile at var/log/prod.log, around the time when the problem happened.
```
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
**Desktop/Smartphone**
Please complete the following information if this seems to be a frontend issue:
- Device: [e.g. iPhone6s]
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
- Kimai version
- PHP version

View File

@@ -1,6 +1,6 @@
---
name: Feature request
about: Suggest an idea for this project
about: Suggest an idea to make Kimai better
---

View File

@@ -1,5 +1,5 @@
## Description
A clear and concise description of what this pull request changes.
## Types of changes
- [ ] Bug fix (non-breaking change which fixes an issue)
@@ -7,8 +7,7 @@
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
## Checklist
- [ ] My code follows the code style
- [ ] All files have a license header
- [ ] All methods have a doc header with type declarations
- [ ] I have executed `bin/console kimai:phpcs --fix` to make sure my changes adopt the correct code style
- [ ] I have updated the documentation accordingly
- [ ] I have added tests to cover my changes
- [ ] I agree that this code is used in Kimai and will be published under the [MIT license](https://github.com/kevinpapst/kimai2/blob/master/LICENSE)

View File

@@ -1,4 +1,4 @@
unreleased=true
future-release=0.7
future-release=0.8
exclude-labels=duplicate,question,invalid,wontfix,release
enhancement_labels=>enhancement,Enhancement,feature request

View File

@@ -1,6 +1,10 @@
build:
environment:
node: v8.11.2
php:
version: 7.2
ini:
date.timezone: "UTC"
nodes:
analysis:
tests:

View File

@@ -20,6 +20,7 @@ matrix:
before_install:
- phpenv config-rm xdebug.ini
- composer self-update
- php -i
install:
- composer install

View File

@@ -1,5 +1,61 @@
# Change Log
## [0.8](https://github.com/kevinpapst/kimai2/tree/0.8) (2019-02-19)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.7...0.8)
**Implemented enhancements:**
- Export other users' timesheets using the API [\#562](https://github.com/kevinpapst/kimai2/issues/562)
- Some importer bugs/improvements [\#553](https://github.com/kevinpapst/kimai2/issues/553)
- Export timesheet only exports entries on the current page [\#534](https://github.com/kevinpapst/kimai2/issues/534)
- On the last activities dropdown show tha last ten DIFFERENT activities [\#533](https://github.com/kevinpapst/kimai2/issues/533)
- Better user expierence on "My Times" [\#526](https://github.com/kevinpapst/kimai2/issues/526)
- Highlight current day in datepickers and not only the selected day [\#522](https://github.com/kevinpapst/kimai2/issues/522)
- Configuration of first page after login [\#501](https://github.com/kevinpapst/kimai2/issues/501)
- Visual Grouping of entry from same day or same week with some stats of that group [\#495](https://github.com/kevinpapst/kimai2/issues/495)
**Fixed bugs:**
- Error 404 on 'My times' page 2+ if the time range is changed to something smaller [\#558](https://github.com/kevinpapst/kimai2/issues/558)
- Timezone is not working correctly [\#554](https://github.com/kevinpapst/kimai2/issues/554)
- New time picker not working on languages without updated translation [\#546](https://github.com/kevinpapst/kimai2/issues/546)
- Export timesheet only exports entries on the current page [\#534](https://github.com/kevinpapst/kimai2/issues/534)
- Kimai2 will not allow me to enter times in the future [\#531](https://github.com/kevinpapst/kimai2/issues/531)
- No projects in filter section [\#525](https://github.com/kevinpapst/kimai2/issues/525)
- This value should be greater than or equal to zero [\#511](https://github.com/kevinpapst/kimai2/issues/511)
- Wrong time after migration kimai v1 data [\#507](https://github.com/kevinpapst/kimai2/issues/507)
- SQL-Error for getRecentActivities\(\) caused by GROUP BY [\#488](https://github.com/kevinpapst/kimai2/issues/488)
**Closed issues:**
- Feedback for improving installation setup [\#541](https://github.com/kevinpapst/kimai2/issues/541)
- template entry.end\_time shows empty field [\#535](https://github.com/kevinpapst/kimai2/issues/535)
**Merged pull requests:**
- handle deleted user during import from v1 [\#569](https://github.com/kevinpapst/kimai2/pull/569) ([kevinpapst](https://github.com/kevinpapst))
- fix pagination in combination with daterange picker [\#568](https://github.com/kevinpapst/kimai2/pull/568) ([kevinpapst](https://github.com/kevinpapst))
- allow to query other users timesheets via api [\#563](https://github.com/kevinpapst/kimai2/pull/563) ([kevinpapst](https://github.com/kevinpapst))
- Updated HTML invoice templates [\#560](https://github.com/kevinpapst/kimai2/pull/560) ([kevinpapst](https://github.com/kevinpapst))
- updated composer packages [\#559](https://github.com/kevinpapst/kimai2/pull/559) ([kevinpapst](https://github.com/kevinpapst))
- fix timezone problems in timesheet forms [\#555](https://github.com/kevinpapst/kimai2/pull/555) ([kevinpapst](https://github.com/kevinpapst))
- Daily stats in timesheet [\#552](https://github.com/kevinpapst/kimai2/pull/552) ([kevinpapst](https://github.com/kevinpapst))
- Added more php requirements to documentation [\#551](https://github.com/kevinpapst/kimai2/pull/551) ([infeeeee](https://github.com/infeeeee))
- improve recent activities [\#550](https://github.com/kevinpapst/kimai2/pull/550) ([kevinpapst](https://github.com/kevinpapst))
- scss fixes: year selector width, dropdown menu width, navbar refactoring [\#549](https://github.com/kevinpapst/kimai2/pull/549) ([infeeeee](https://github.com/infeeeee))
- improved installation docs [\#548](https://github.com/kevinpapst/kimai2/pull/548) ([kevinpapst](https://github.com/kevinpapst))
- fix daterange-picker for fr, hu and ar [\#547](https://github.com/kevinpapst/kimai2/pull/547) ([kevinpapst](https://github.com/kevinpapst))
- do not limit users timesheet export page size [\#545](https://github.com/kevinpapst/kimai2/pull/545) ([kevinpapst](https://github.com/kevinpapst))
- Use Symfony formatter for currency symbol placement [\#542](https://github.com/kevinpapst/kimai2/pull/542) ([sanjitlpatel](https://github.com/sanjitlpatel))
- Localized date-inputs and daterange-picker [\#540](https://github.com/kevinpapst/kimai2/pull/540) ([kevinpapst](https://github.com/kevinpapst))
- added export module [\#538](https://github.com/kevinpapst/kimai2/pull/538) ([kevinpapst](https://github.com/kevinpapst))
- updated documentation [\#536](https://github.com/kevinpapst/kimai2/pull/536) ([kevinpapst](https://github.com/kevinpapst))
- support remote data in beta-test selectpicker [\#529](https://github.com/kevinpapst/kimai2/pull/529) ([kevinpapst](https://github.com/kevinpapst))
- Improve allowed running records [\#528](https://github.com/kevinpapst/kimai2/pull/528) ([kevinpapst](https://github.com/kevinpapst))
- Improve daterangepicker [\#527](https://github.com/kevinpapst/kimai2/pull/527) ([kevinpapst](https://github.com/kevinpapst))
- added configurable view after login [\#523](https://github.com/kevinpapst/kimai2/pull/523) ([kevinpapst](https://github.com/kevinpapst))
- convert timesheets to UTC with support for user timezone [\#372](https://github.com/kevinpapst/kimai2/pull/372) ([kevinpapst](https://github.com/kevinpapst))
## [0.7](https://github.com/kevinpapst/kimai2/tree/0.7) (2019-01-28)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.6.1...0.7)
@@ -18,11 +74,10 @@
- Configuration option to disable fixed rate and hourly rate from "edit timesheet" [\#330](https://github.com/kevinpapst/kimai2/issues/330)
- Set other users hourly rate [\#303](https://github.com/kevinpapst/kimai2/issues/303)
- Feature request - Make "Rate" hideable [\#217](https://github.com/kevinpapst/kimai2/issues/217)
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- This value should be greater than or equal to zero [\#511](https://github.com/kevinpapst/kimai2/issues/511)
- Timesheet Export for Admins [\#503](https://github.com/kevinpapst/kimai2/issues/503)
- admin activity: visibility "none" \(no filter\) causes sql-error [\#491](https://github.com/kevinpapst/kimai2/issues/491)
- login-screen optimizations [\#483](https://github.com/kevinpapst/kimai2/issues/483)
@@ -51,7 +106,7 @@
- pagination without reload while keeping filters applied [\#521](https://github.com/kevinpapst/kimai2/pull/521) ([kevinpapst](https://github.com/kevinpapst))
- go back to calendar after editing and creation of time-records [\#519](https://github.com/kevinpapst/kimai2/pull/519) ([kevinpapst](https://github.com/kevinpapst))
- fetch toolbar results without page reload [\#518](https://github.com/kevinpapst/kimai2/pull/518) ([kevinpapst](https://github.com/kevinpapst))
- fetch toolbar results without page reload [\#518](https://github.com/kevinpapst/kimai2/pull/518) ([kevinpapst](https://github.com/kevinpapst))
- Form and theme improvements [\#513](https://github.com/kevinpapst/kimai2/pull/513) ([kevinpapst](https://github.com/kevinpapst))
- validation for future and negative times [\#512](https://github.com/kevinpapst/kimai2/pull/512) ([kevinpapst](https://github.com/kevinpapst))
- alphabetical order for selectboxes [\#510](https://github.com/kevinpapst/kimai2/pull/510) ([kevinpapst](https://github.com/kevinpapst))

View File

@@ -23,9 +23,9 @@ Kimai is a [multi-language application](var/docs/translations.md) and already tr
### Requirements
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- The PHP extensions [xml](http://php.net/manual/en/book.xml.php), [mbstring](http://php.net/manual/en/book.mbstring.php), [gd](http://php.net/manual/en/book.image.php), [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- Kimai needs its own sub-domain or you need to [recompile the frontend assets](var/docs/developers.md) for usage in a sub-directory
- If you use MariaDB, make sure its at least v10.2.7 (see [FAQ](var/docs/faq.md))
- Kimai needs to be installed in the root directory of a domain or you need to [recompile the frontend assets](var/docs/developers.md)
- A modern browser, Kimai v2 might be broken on old browsers like IE 10
## Documentation
@@ -56,12 +56,12 @@ You can see our development roadmap in the [Milestones](https://github.com/kevin
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome!
> Kimai 2 uses a rolling release concept for delivering updates.
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
> You can upgrade Kimai at any time, you don't need to wait for the next official release. Read the [upgrade docs](UPGRADING.md) first!
Release versions will be created on a regular base and you can use these tags if you are familiar with Git,
but we will not provide support for any specific version.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch and
intensively tested before merging. We have to do it this way, as we develop Kimai in our free time and want to put our
Every code change, whether it's a new feature or a bug fix, will be done on the master branch.
I have to do it this way, as I develop Kimai in my free time and want to put most
effort into the software instead of backporting changes for old versions.
## Extensions for Kimai 2

View File

@@ -1,38 +1,51 @@
# Upgrading Kimai 2
Database upgrades are currently ONLY provided for MySQL/MariaDB and SQLite.
_Database upgrades are currently ONLY provided for MySQL/MariaDB and SQLite._
If you plan on using e.g. PostgreSQL, please read more about the `bin/console doctrine:migrations:diff` and
`bin/console doctrine:migrations:migrate` commands and contact us, so we can integrate them into the official releases.
A normal upgrade can be executed with these commands:
Upgrading to the latest available version can be achieved with these commands:
```bash
git pull origin master
git fetch --tags
git checkout 0.8
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
bin/console doctrine:migrations:migrate
```
There might be version specific tasks that need to be executed before or after these steps, please see below
if your updated version is mentioned below.
There might be more steps required which are version specific and need to be executed after following the above list of commands.
Read and follow each version info below, otherwise you risk data inconsistency or a broken installation!
## [0.7](https://github.com/kevinpapst/kimai2/releases/tag/0.7) (2019-01-28)
And make sure to **create a backup before you start**.
## [0.8](https://github.com/kevinpapst/kimai2/releases/tag/0.8)
After you followed the normal update and database migration process (see above), you need to execute a bash command to convert your timesheet data for timezone support:
- Read this [pull request](https://github.com/kevinpapst/kimai2/pull/372) BEFORE you follow the instructions to convert the
timezones in your existing time records with `bin/console kimai:convert-timezone`. Without that, you will end up with wrong times in your database.
Check if you want to apply changes to your `local.yaml`:
- A new boolean setting `kimai.timesheet.rules.allow_future_times` was introduced
- New permissions are available: `view_export, create_export, edit_export_own_timesheet, edit_export_other_timesheet, system_information`
## [0.7](https://github.com/kevinpapst/kimai2/releases/tag/0.7)
The configuration `kimai.theme.active_warning` was deprecated and should be replaced in your local.yaml,
[read config docs for more information](https://github.com/kevinpapst/kimai2/blob/master/var/docs/configurations.md#limit-active-entries).
## [0.6.1](https://github.com/kevinpapst/kimai2/releases/tag/0.6.1) (2018-11-19)
## [0.6.1](https://github.com/kevinpapst/kimai2/releases/tag/0.6.1)
A bugfix release to address database compatibility issues with older MySQL/MariaDB versions.
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6) (2018-11-18)
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6)
The API has some minor BC breaks: some fields were renamed and entities have a larger attribute set than collections.
Be aware that the API is still is development mode and shouldn't be considered stable for now.
## [0.5](https://github.com/kevinpapst/kimai2/releases/tag/0.5) (2018-09-27)
## [0.5](https://github.com/kevinpapst/kimai2/releases/tag/0.5)
Some configuration nodes were removed, if you have one of them in your `local.yaml` you need to delete them before you start the update:
- `kimai.invoice.calculator`
@@ -44,7 +57,7 @@ The new config `kimai.invoice.documents` was introduced, holding a list of direc
**BC break:** InvoiceTemplate name was changed from 255 characters to 60. If you used longer invoice-template names, they will be truncated when upgrading the database.
Please make sure that they are unique in the first 60 character before you upgrade your database with `doctrine:migrations:migrate`.
## [0.4](https://github.com/kevinpapst/kimai2/releases/tag/0.4) (2018-09-01)
## [0.4](https://github.com/kevinpapst/kimai2/releases/tag/0.4)
In the time between 0.3 and 0.4 there was a release of composer that introduced a BC break,
which leads to problems between Composer and Symfony Flex, resulting in an error like this when running it:
@@ -60,19 +73,7 @@ sudo composer self-update
sudo -u www-data composer update symfony/flex --no-plugins --no-scripts
```
Then the full update can be executed as usual:
```bash
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
bin/console doctrine:migrations:migrate
```
## [0.3](https://github.com/kevinpapst/kimai2/releases/tag/0.3) (2018-07-22)
**Update from 0.2:**
## [0.3](https://github.com/kevinpapst/kimai2/releases/tag/0.3)
You need to adjust your `.env` file and add your `from` address for [all emails](https://github.com/kevinpapst/kimai2/blob/master/var/docs/emails.md) generated by Kimai 2:
```

View File

@@ -5,10 +5,17 @@
* file that was distributed with this source code.
*/
/** global: jQuery */
/** global: moment */
if (typeof jQuery === 'undefined') {
throw new Error('Kimai requires jQuery');
}
if (typeof moment === 'undefined') {
throw new Error('Kimai requires moment.js');
}
/* kimai
*
* @type Object
@@ -51,37 +58,87 @@ $(function() {
);
}
$('input[data-datepicker="on"]').daterangepicker({
singleDatePicker: true,
showDropdowns: true,
autoUpdateInput: false,
locale: {
format: "YYYY-MM-DD",
firstDay: 1
}
// ==== compound field in toolbar ====
$('input[data-daterangepickerenable="on"]').each(function(index) {
var localeFormat = $(this).data('format');
var separator = $(this).data('separator');
var rangesList = {};
rangesList[$.kimai.settings['today']] = [moment(), moment()];
rangesList[$.kimai.settings['yesterday']] = [moment().subtract(1, 'days'), moment().subtract(1, 'days')];
rangesList[$.kimai.settings['thisWeek']] = [moment().startOf('week'), moment().endOf('week')];
rangesList[$.kimai.settings['lastWeek']] = [moment().subtract(1, 'week').startOf('week'), moment().subtract(1, 'week').endOf('week')];
rangesList[$.kimai.settings['thisMonth']] = [moment().startOf('month'), moment().endOf('month')];
rangesList[$.kimai.settings['lastMonth']] = [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')];
rangesList[$.kimai.settings['thisYear']] = [moment().startOf('year'), moment().endOf('year')];
rangesList[$.kimai.settings['lastYear']] = [moment().subtract(1, 'year').startOf('year'), moment().subtract(1, 'year').endOf('year')];
$(this).daterangepicker({
showDropdowns: true,
autoUpdateInput: false,
autoApply: false,
linkedCalendars: false,
locale: {
separator: separator,
format: localeFormat,
firstDay: 1,
applyLabel: $.kimai.settings['apply'],
cancelLabel: $.kimai.settings['cancel'],
customRangeLabel: $.kimai.settings['customRange']
},
ranges: rangesList,
alwaysShowCalendars: true
});
$(this).on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
$(this).trigger("change");
});
});
$('input[data-datepicker="on"]').on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format('YYYY-MM-DD'));
$(this).trigger("change");
// ==== single select boxes in toolbars ====
$('input[data-datepickerenable="on"]').each(function(index) {
var localeFormat = $(this).data('format');
$(this).daterangepicker({
singleDatePicker: true,
showDropdowns: true,
autoUpdateInput: false,
locale: {
format: localeFormat,
firstDay: 1,
applyLabel: $.kimai.settings['apply'],
cancelLabel: $.kimai.settings['cancel'],
customRangeLabel: $.kimai.settings['customRange']
}
});
$(this).on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format(localeFormat));
$(this).trigger("change");
});
});
$('input[data-datetimepicker="on"]').daterangepicker({
singleDatePicker: true,
timePicker: true,
timePicker24Hour: true,
showDropdowns: true,
autoUpdateInput: false,
locale: {
cancelLabel: 'Clear',
format: "YYYY-MM-DD HH:mm",
firstDay: 1
}
});
// ==== edit timesheet - date with time ====
$('input[data-datetimepicker="on"]').each(function(index) {
var localeFormat = $(this).data('format');
$(this).daterangepicker({
singleDatePicker: true,
timePicker: true,
timePicker24Hour: true,
showDropdowns: true,
autoUpdateInput: false,
locale: {
format: localeFormat,
firstDay: 1,
applyLabel: $.kimai.settings['apply'],
cancelLabel: $.kimai.settings['cancel'],
customRangeLabel: $.kimai.settings['customRange']
}
});
$('input[data-datetimepicker="on"]').on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format('YYYY-MM-DD HH:mm'));
$(this).trigger("change");
$(this).on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format(localeFormat));
$(this).trigger("change");
});
});
$('select[data-related-select]').change(function() {
@@ -111,7 +168,11 @@ $(function() {
$select.append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
// if we don't trigger the change, the other selects won't be resetted
$select.trigger('change');
// if the beta test kimai.theme.select_type is active, this will tell the selects to refresh
$('.selectpicker').selectpicker('refresh');
}
});
});
@@ -142,8 +203,19 @@ $(function() {
// default values
$.kimai.defaults = {
locale: 'en',
alertSuccessAutoHide: 5000,
confirmDelete: 'Really delete?',
alertSuccessAutoHide: 5000
today: 'Today',
yesterday: 'Yesterday',
apply: 'Apply',
cancel: 'Cancel',
thisWeek: 'This week',
lastWeek: 'Last week',
thisMonth: 'This month',
lastMonth: 'Last month',
thisYear: 'This year',
lastYear: 'Last year',
customRange: 'Custom range'
};
// once initialized, here are all values

View File

@@ -22,10 +22,6 @@ $(document).ready(function () {
});
$('.toolbar form input').change(function (event) {
toolbarLoadDataAfterChange();
});
$('.toolbar form select').change(function (event) {
switch (event.target.id) {
case 'page':
break;
@@ -35,6 +31,11 @@ $(document).ready(function () {
toolbarLoadDataAfterChange();
});
$('.toolbar form select').change(function (event) {
$('.toolbar form input#page').val(1);
toolbarLoadDataAfterChange();
});
function toolbarLoadDataAfterChange()
{
var $form = $('.toolbar form');

View File

@@ -38,3 +38,4 @@ footer.main-footer {
@import 'dashboard';
@import 'navbar';
@import 'documentation';
@import 'daterangepicker';

View File

@@ -120,9 +120,14 @@ table.dataTable thead .sorting_desc_disabled:after {
}
table.dataTable thead > tr > th {
padding-right: 30px;
white-space: nowrap;
}
/*
table.dataTable thead > tr > th {
padding-right: 30px;
}
*/
table.dataTable th:active {
outline: none;
}
@@ -223,14 +228,22 @@ div.dataTables_scrollHead table.table-bordered {
background-color: #017ebc;
}
/*
Make sure that the action buttons do not line-break if another column takes all available space
See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952
*/
table.dataTable {
/* summary row, like the timesheet table provides */
tr.summary td {
font-weight: bold;
border-bottom: 1px solid #ccc;
}
td {
/*
Make sure that the action buttons do not line-break if another column takes all available space
See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952
*/
.btn-group {
display: flex;
}
.label {
font-size: 85%;
}
}
}
}

View File

@@ -0,0 +1,12 @@
.drp-calendar {
.calendar-table {
td.today:not(.active) {
background-color: lighten(#357ebd, 40);
color: #357ebd;
}
select.yearselect{
width: unset;
}
}
}

View File

@@ -5,18 +5,14 @@
* file that was distributed with this source code.
*/
body.invoice_print
{
margin: 20px;
}
.invoice {
margin: 0;
border: none;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
margin: 25px;
}
}
@@ -43,7 +39,7 @@ table.invoice-sum th, table.invoice-sum td {
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
font: 16px/1 'Open Sans', sans-serif;
font: 15px/1 $font-family-sans-serif;
box-sizing: border-box;
h1 {
@@ -94,6 +90,7 @@ table.invoice-sum th, table.invoice-sum td {
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
@@ -106,8 +103,8 @@ table.invoice-sum th, table.invoice-sum td {
/* article */
article, article address, table.meta, table.inventory {
margin: 2em 0 2em;
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
@@ -138,7 +135,7 @@ table.invoice-sum th, table.invoice-sum td {
table.meta {
float: right;
width: 50%;
font-size: 90%;
font-size: 85%;
th {
text-align: right;
@@ -285,7 +282,7 @@ table.invoice-sum th, table.invoice-sum td {
* {
-webkit-print-color-adjust: exact;
}
body, .invoice, .wrapper, #freelancer-invoice {
body, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
}
@@ -301,4 +298,8 @@ table.invoice-sum th, table.invoice-sum td {
display: none;
}
}
.invoice {
width: unset;
}
}

View File

@@ -5,22 +5,25 @@
* file that was distributed with this source code.
*/
.navbar-nav > li > a.ddt-large {
padding-top: 12px;
padding-bottom: 9px;
}
li.messages-menu ul.menu li .pull-left i {
color: #dd4b39;
}
.navbar-nav>.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.navbar-nav .start_record {
text-align: center;
.navbar-nav {
li>a.ddt-large {
padding-top: 12px;
padding-bottom: 9px;
}
li.messages-menu ul.menu li .pull-left i {
color: #dd4b39;
}
.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.start_record {
text-align: center;
}
.dropdown-menu {
max-width: 90vw;
width: unset !important;
}
}
/*
@@ -43,4 +46,4 @@ li.open .ticktac i.running{
left: 40px;
padding-top: 0px;
}
*/
*/

View File

@@ -25,6 +25,10 @@
}
}
.toolbar-pad {
padding: 10px;
}
/* The filter form is available on most pages above the datatable */
@media (min-width: $screen-sm-min) {
.toolbar {
@@ -43,5 +47,8 @@
}
}
}
.toolbar-pad {
padding: 10px 0;
}
}

View File

@@ -5,10 +5,12 @@
"description": "Kimai Time-Tracking 2",
"require": {
"php": "^7.1.3",
"ext-gd": "*",
"ext-intl": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0",
"beberlei/doctrineextensions": "^1.2",
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"erusev/parsedown": "^1.6",
@@ -17,6 +19,7 @@
"fzaninotto/faker": "^1.8",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "~2.1",
"mpdf/mpdf": "^7.1",
"nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1",
"phpoffice/phpspreadsheet": "^1.4",
@@ -30,6 +33,7 @@
"symfony/flex": "^1.0",
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/intl": "^4.0",
"symfony/maker-bundle": "^1.0",
"symfony/monolog-bundle": "^3.1",
"symfony/orm-pack": "^1.0",
@@ -78,6 +82,9 @@
"symfony/polyfill-php70": "*",
"symfony/polyfill-php56": "*"
},
"suggest": {
"ext-mbstring": "If ext-mbstring is not available you MUST install symfony/polyfill-mbstring"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",

1464
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@ doctrine:
# With Symfony 3.3, remove the `resolve:` prefix
url: '%env(resolve:DATABASE_URL)%'
types:
datetime: App\Doctrine\UTCDateTimeType
orm:
auto_generate_proxy_classes: '%kernel.debug%'
naming_strategy: doctrine.orm.naming_strategy.underscore

View File

@@ -47,6 +47,11 @@ kimai:
soft_limit: 1
hard_limit: 3
# Rules that define the Timesheet validation and behaviour
rules:
# whether records in the future can be created
allow_future_times: true
# --------------------------------------------------------------------------------
# Invoice management
#invoice:
@@ -66,8 +71,8 @@ kimai:
# --------------------------------------------------------------------------------
# Free configurable permission system, see var/docs/permissions.md
permissions:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
sets:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity]
PROJECTS: [view_project,create_project,edit_project,delete_project]
CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer]
@@ -80,58 +85,95 @@ kimai:
USER: [view_user,create_user,delete_user]
RATE: [view_rate_own_timesheet,edit_rate_own_timesheet]
RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet]
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
EXPORT: [view_export,create_export,edit_export_own_timesheet,edit_export_other_timesheet]
maps:
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
ROLE_USER: [TIMESHEET,PROFILE]
ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE]
ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER]
ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER]
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE,EXPORT]
ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER,EXPORT]
ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER,EXPORT]
roles:
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile,system_information]
# --------------------------------------------------------------------------------
# Language specific settings, like the date formats
languages:
de:
date_short: 'd.m.Y'
duration: '%%h:%%m Stunden'
duration_short: '%%h:%%m h'
date_time_type: 'dd.MM.yyyy HH:mm'
date_time_picker: 'DD.MM.YYYY HH:mm'
date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
en:
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
date_time_type: 'yyyy-MM-dd HH:mm'
date_time_picker: 'YYYY-MM-DD HH:mm'
date_type: 'yyyy-MM-dd'
date_picker: 'YYYY-MM-DD'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
pt_BR:
date_short: 'd-m-Y'
duration: '%%h:%%m horas'
duration_short: '%%h:%%m h'
date_time_type: 'dd-MM-yyyy HH:mm'
date_time_picker: 'DD-MM-YYYY HH:mm'
date_type: 'dd-MM-yyyy'
date_picker: 'DD-MM-YYYY'
date: 'd-m-Y'
date_time: 'd-m H:i'
duration: '%%h:%%m h'
it:
date_short: 'd.m.Y'
date_time_type: 'dd.MM.yyyy HH:mm'
date_time_picker: 'DD.MM.YYYY HH:mm'
date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
duration_short: '%%h:%%m h'
fr:
date_short: 'd/m/Y'
duration: '%%h h %%m min'
duration_short: '%%h h %%m'
date_time_type: 'dd/MM/yyyy HH:mm'
date_time_picker: 'DD/MM/YYYY HH:mm'
date_type: 'dd/MM/yyyy'
date_picker: 'DD/MM/YYYY'
date: 'd/m/Y'
date_time: 'd/m H:i'
duration: '%%h h %%m'
es:
date_short: 'd.m.Y'
date_time_type: 'dd.MM.yyyy HH:mm'
date_time_picker: 'DD.MM.YYYY HH:mm'
date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
duration_short: '%%h:%%m h'
ru:
date_short: 'd.m.Y'
duration: '%%h:%%m Часов'
duration_short: '%%h:%%m h'
date_time_type: 'dd.MM.yyyy HH:mm'
date_time_picker: 'DD.MM.YYYY HH:mm'
date_type: 'dd.MM.yyyy'
date_picker: 'DD.MM.YYYY'
date: 'd.m.Y'
date_time: 'd.m. H:i'
duration: '%%h:%%m h'
ar:
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
date_time_type: 'yyyy-MM-dd HH:mm'
date_time_picker: 'YYYY-MM-DD HH:mm'
date_type: 'yyyy-MM-dd'
date_picker: 'YYYY-MM-DD'
date: 'Y-m-d'
date_time: 'm-d H:i'
duration: '%%h:%%m h'
hu:
date_short: 'Y.m.d.'
duration: '%%h:%%m óra'
duration_short: '%%h:%%m h'
date_time_type: 'yyyy.MM.dd HH:mm'
date_time_picker: 'YYYY.MM.DD HH:mm'
date_type: 'yyyy.MM.dd.'
date_picker: 'YYYY.MM.DD.'
date: 'Y.m.d.'
date_time: 'm.d. H:i'
duration: '%%h:%%m h'
# --------------------------------------------------------------------------------
# timesheet calendar configuration

View File

@@ -2,17 +2,12 @@ nelmio_api_doc:
models:
use_jms: true
names:
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] }
- { alias: CustomerCollection, type: App\Entity\Customer, groups: [Default, Collection, Customer] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] }
- { alias: ProjectCollection, type: App\Entity\Project, groups: [Default, Collection, Project] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: ActivityCollection, type: App\Entity\Activity, groups: [Default, Collection, Activity] }
- { alias: TimesheetFormEntity, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
- { alias: CustomerEntity, type: App\Entity\Customer, groups: [Default, Entity, Customer] }
- { alias: ProjectEntity, type: App\Entity\Project, groups: [Default, Entity, Project] }
- { alias: ActivityEntity, type: App\Entity\Activity, groups: [Default, Entity, Activity] }
- { alias: TimesheetEditForm, type: App\Form\TimesheetEditForm, groups: [Default, Entity, Timesheet] }
- { alias: TimesheetEntity, type: App\Entity\Timesheet, groups: [Default, Entity, Timesheet] }
- { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
areas:
path_patterns:
- ^/api(?!/doc)

View File

@@ -1,6 +1,6 @@
App\Entity\Timesheet:
exclusion_policy: All
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate]
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate, exported]
properties:
id:
include: true
@@ -16,7 +16,7 @@ App\Entity\Timesheet:
groups: [Default]
description:
include: true
groups: [Entity]
groups: [Default]
rate:
include: true
groups: [Default]
@@ -26,6 +26,9 @@ App\Entity\Timesheet:
hourlyRate:
include: true
groups: [Entity]
exported:
include: true
groups: [Entity]
activity:
include: false
exclude: true

View File

@@ -54,7 +54,11 @@ services:
App\Controller\Admin\CustomerController:
arguments:
$defaults: "%kimai.defaults%"
App\Utils\LocaleSettings:
arguments:
$languageSettings: "%kimai.languages%"
# ================================================================================
# DATABASE
# ================================================================================
@@ -101,18 +105,14 @@ services:
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
App\Validator\Constraints\TimesheetValidator:
arguments:
$ruleset: "%kimai.timesheet.rules%"
# ================================================================================
# THEME
# ================================================================================
App\Twig\Extensions:
arguments:
$languages: "%kimai.languages%"
App\Twig\DateExtensions:
arguments:
$dateSettings: "%kimai.languages%"
KevinPapst\AdminLTEBundle\Helper\ContextHelper:
alias: admin_lte_theme.context_helper

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"build/app.js": "/build/app.js?5b6a5a9160ea779a403e",
"build/app.css": "/build/app.css?fc142c29c80f5dcdbc4c745af16509f6",
"build/app.js": "/build/app.js?98fd89cf83295df4d009",
"build/app.css": "/build/app.css?51fa08d868a6a2e03d602ee2df728169",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",

View File

@@ -53,7 +53,10 @@ class ActivityController extends BaseApiController
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref="#/definitions/ActivityCollection"),
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/ActivityEntity")
* )
* )
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter activities. If none is provided, only global activities will be returned.")
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
@@ -80,11 +83,11 @@ class ActivityController extends BaseApiController
$query->setOrderBy($orderBy);
}
if (null !== ($globals = $paramFetcher->get('globals'))) {
if (null !== $paramFetcher->get('globals')) {
$query->setGlobalsOnly(true);
}
if ('false' === ($globalsFirst = $paramFetcher->get('globalsFirst'))) {
if ('false' === $paramFetcher->get('globalsFirst')) {
$query->setOrderGlobalsFirst(false);
}

View File

@@ -53,7 +53,10 @@ class CustomerController extends BaseApiController
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref="#/definitions/CustomerCollection"),
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/CustomerEntity")
* )
* )
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")

View File

@@ -53,7 +53,10 @@ class ProjectController extends BaseApiController
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref="#/definitions/ProjectCollection"),
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/ProjectEntity")
* )
* )
* @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)")

View File

@@ -15,6 +15,7 @@ use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
@@ -46,24 +47,36 @@ class TimesheetController extends BaseApiController
*/
protected $hardLimit;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, int $hardLimit)
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, UserDateTimeFactory $dateTime, int $hardLimit)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->hardLimit = $hardLimit;
$this->dateTime = $dateTime;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing timesheets for the user",
* @SWG\Schema(ref="#/definitions/TimesheetCollection"),
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/TimesheetEntity")
* )
* )
*
* @Rest\QueryParam(name="user", requirements="\d+|all", strict=true, nullable=true, description="User ID to filter timesheets (needs permission 'view_other_timesheet', pass 'all' to fetch data for all user)")
* @Rest\QueryParam(name="customer", requirements="\d+", strict=true, nullable=true, description="Customer ID to filter timesheets")
* @Rest\QueryParam(name="project", requirements="\d+", strict=true, nullable=true, description="Project ID to filter timesheets")
* @Rest\QueryParam(name="activity", requirements="\d+", strict=true, nullable=true, description="Activity ID to filter timesheets")
@@ -72,7 +85,7 @@ class TimesheetController extends BaseApiController
* @Rest\QueryParam(name="order", requirements="ASC|DESC", strict=true, nullable=true, description="The result order (allowed values: 'ASC', 'DESC')")
* @Rest\QueryParam(name="orderBy", requirements="id|begin|end|rate", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'begin', 'end', 'rate')")
*
* @Security("is_granted('view_own_timesheet')")
* @Security("is_granted('view_own_timesheet') or is_granted('view_other_timesheet')")
*
* @return Response
*/
@@ -82,6 +95,13 @@ class TimesheetController extends BaseApiController
$query->setUser($this->getUser());
$query->setResultType(TimesheetQuery::RESULT_TYPE_PAGER);
if ($this->isGranted('view_other_timesheet') && null !== ($user = $paramFetcher->get('user'))) {
if ('all' === $user) {
$user = null;
}
$query->setUser($user);
}
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
@@ -147,31 +167,39 @@ class TimesheetController extends BaseApiController
/**
* @SWG\Post(
* description="Creates a new timesheet entry and returns it afterwards",
* @SWG\Schema(ref="#/definitions/TimesheetFormEntity"),
* @SWG\Response(
* response=200,
* description="Returns the new created timesheet entry",
* @SWG\Schema(ref="#/definitions/TimesheetEntity"),
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/TimesheetEditForm")
* )
*
* @Security("is_granted('create_own_timesheet')")
*
* @param Request $request
* @return Response
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function postAction(Request $request)
{
$timesheet = new Timesheet();
$timesheet->setUser($this->getUser());
$timesheet->setBegin(new \DateTime());
$timesheet->setBegin($this->dateTime->createDateTime());
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]);
$form->setData($timesheet);
$form->submit($request->request->all());
if ($form->isValid()) {
@@ -194,10 +222,13 @@ class TimesheetController extends BaseApiController
}
}
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->hardLimit
);
if (null === $timesheet->getEnd()) {
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->hardLimit
);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
@@ -213,4 +244,69 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* @SWG\Patch(
* description="Update an existing timesheet entry, you can pass all or just a subset of all attributes",
* @SWG\Response(
* response=200,
* description="Returns the updated timesheet entry",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
* )
* @SWG\Parameter(
* name="body",
* in="body",
* required=true,
* @SWG\Schema(ref="#/definitions/TimesheetEditForm")
* )
*
* @param Request $request
* @param string $id
* @return Response
*/
public function patchAction(Request $request, string $id)
{
$timesheet = $this->repository->find($id);
if (!$this->isGranted('edit', $timesheet)) {
throw $this->createAccessDeniedException('User cannot update timesheet');
}
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]);
$form->setData($timesheet);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -49,7 +49,10 @@ class UserController extends BaseApiController
* @SWG\Response(
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref="#/definitions/UserCollection"),
* @SWG\Schema(
* type="array",
* @SWG\Items(ref="#/definitions/UserEntity")
* )
* )
*
* @Security("is_granted('view_user')")

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
class BashExecutor
{
/**
* @var string
*/
protected $rootDir;
/**
* @param string $projectDirectory
*/
public function __construct(string $projectDirectory)
{
$this->rootDir = realpath($projectDirectory);
}
/**
* @param string $command
* @return BashResult
*/
public function execute(string $command)
{
$exitCode = 0;
$command = rtrim($this->rootDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($command, DIRECTORY_SEPARATOR);
ob_start();
passthru($command, $exitCode);
$result = ob_get_clean();
return new BashResult($exitCode, $result);
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
class BashResult
{
/**
* @var string
*/
protected $exitCode;
/**
* @var string
*/
protected $result;
/**
* @param string $exitCode
* @param string $result
*/
public function __construct($exitCode, $result)
{
$this->exitCode = $exitCode;
$this->result = $result;
}
/**
* @return string
*/
public function getExitCode(): string
{
return $this->exitCode;
}
/**
* @param string $exitCode
* @return BashResult
*/
public function setExitCode(string $exitCode)
{
$this->exitCode = $exitCode;
return $this;
}
/**
* @return string
*/
public function getResult(): string
{
return $this->result;
}
/**
* @param string $result
* @return BashResult
*/
public function setResult(string $result)
{
$this->result = $result;
return $this;
}
}

View File

@@ -0,0 +1,149 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Entity\Timesheet;
use App\Repository\TimesheetRepository;
use Doctrine\DBAL\Types\DateTimeType;
use Doctrine\DBAL\Types\Type;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command was added with v0.8.
* Kimai saved the DateTime before with the local timezone, which can cause big problems when used in different environments.
* You should convert all timesheet records that were saved with Kimai 2 directly, but NOT the ones migrated from Kimai v1.
*
* Please read https://github.com/kevinpapst/kimai2/pull/372 to find out more!
*
* @codeCoverageIgnore
*/
class ConvertTimezoneCommand extends Command
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @param TimesheetRepository $repository
*/
public function __construct(TimesheetRepository $repository)
{
$this->repository = $repository;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:convert-timezone')
->setDescription('Convert timesheet dates between timezones, only made for updates from 0.7 to 0.8')
->setHelp('This command should only be required if you imported data from Kimai v1')
->addOption('first-id', 'f', InputArgument::OPTIONAL, 'The database ID which should be converted first')
->addOption('last-id', 'l', InputArgument::OPTIONAL, 'The database ID which should be converted last')
;
}
/**
* @param null|string $start
* @param null|string $end
* @return Timesheet[]
*/
protected function getTimesheets($start = null, $end = null)
{
$qb = $this->repository->createQueryBuilder('t');
$qb->select('t');
if (!empty($start)) {
$qb->andWhere($qb->expr()->gte('t.id', $start));
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lte('t.id', $end));
}
return $qb->getQuery()->getResult();
}
/**
* @param Timesheet $timesheet
* @param \DateTimeZone $timeZone
* @return Timesheet
*/
protected function convertTimesheet(Timesheet $timesheet, \DateTimeZone $timeZone): Timesheet
{
$oldTimezone = $timesheet->getTimezone();
if (null !== $timesheet->getBegin()) {
$beginDate = clone $timesheet->getBegin();
$beginDate->setTimezone($timeZone);
$timesheet->setBegin($beginDate);
}
if (null !== $timesheet->getEnd()) {
$endDate = clone $timesheet->getEnd();
$endDate->setTimezone($timeZone);
$timesheet->setEnd($endDate);
}
$timesheet->setTimezone($oldTimezone);
return $timesheet;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
Type::overrideType(Type::DATETIME, DateTimeType::class);
$io = new SymfonyStyle($input, $output);
$start = $input->getOption('first-id');
$end = $input->getOption('last-id');
$result = $this->getTimesheets($start, $end);
$amount = count($result);
$answer = $io->ask(sprintf('This will update %s timesheet records, continue (y/n) ? ', $amount));
if ('y' !== $answer) {
$io->text('Aborting.');
return;
}
$utc = new \DateTimeZone('UTC');
$i = 0;
/** @var Timesheet $timesheet */
foreach ($result as $timesheet) {
$timesheet = $this->convertTimesheet($timesheet, $utc);
$this->repository->save($timesheet);
if (++$i % 80 === 0) {
$io->writeln('. (' . $i . '/' . $amount . ')');
} else {
$io->write('.');
}
}
$io->writeln('. (' . $i . '/' . $amount . ')');
$io->writeln('');
}
}

View File

@@ -15,10 +15,13 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Common\Persistence\ObjectManager;
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 Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -32,6 +35,8 @@ use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Command used to import data from a Kimai v1 installation.
* Getting help in improving this script would be fantastic, it currently only handles the most basic use-cases.
*
* @codeCoverageIgnore
*/
class KimaiImporterCommand extends Command
{
@@ -123,7 +128,8 @@ class KimaiImporterCommand extends Command
)
->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', 'de')
->addArgument('country', InputArgument::OPTIONAL, 'The default country for customer (2-character uppercase)', 'DE')
->addArgument('currency', InputArgument::OPTIONAL, 'The default currency for customer (code like EUR, CHF, GBP or USD)', 'EUR')
;
}
@@ -132,6 +138,10 @@ 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);
// don't calculate rates ... this was done in Kimai 1
$this->deactivateLifecycleCallbacks($this->getDoctrine()->getConnection());
$io = new SymfonyStyle($input, $output);
@@ -151,7 +161,14 @@ class KimaiImporterCommand extends Command
$country = $input->getArgument('country');
if (2 != trim(strlen($country))) {
$io->error('Country length needs to be exactly 2 character');
$io->error('Country code needs to be exactly 2 character');
return;
}
$currency = $input->getArgument('currency');
if (3 != trim(strlen($currency))) {
$io->error('Currency code needs to be exactly 3 character');
return;
}
@@ -243,7 +260,7 @@ class KimaiImporterCommand extends Command
$allImports = 0;
try {
$counter = $this->importUsers($io, $password, $users);
$counter = $this->importUsers($io, $password, $users, $rates);
$allImports += $counter;
$io->success('Imported users: ' . $counter);
} catch (\Exception $ex) {
@@ -253,7 +270,7 @@ class KimaiImporterCommand extends Command
}
try {
$counter = $this->importCustomers($io, $customer, $country);
$counter = $this->importCustomers($io, $customer, $country, $currency);
$allImports += $counter;
$io->success('Imported customers: ' . $counter);
} catch (\Exception $ex) {
@@ -392,16 +409,21 @@ class KimaiImporterCommand extends Command
}
/**
* @param string $table
* @param $table
* @param array $where
* @return array
*/
protected function fetchAllFromImport($table)
protected function fetchAllFromImport($table, array $where = [])
{
return $this->connection->createQueryBuilder()
$query = $this->connection->createQueryBuilder()
->select('*')
->from($this->connection->quoteIdentifier($this->dbPrefix . $table))
->execute()
->fetchAll();
->from($this->connection->quoteIdentifier($this->dbPrefix . $table));
foreach ($where as $column => $value) {
$query->andWhere($query->expr()->eq($column, $value));
}
return $query->execute()->fetchAll();
}
/**
@@ -461,10 +483,11 @@ class KimaiImporterCommand extends Command
* @param SymfonyStyle $io
* @param string $password
* @param array $users
* @param array $rates
* @return int
* @throws \Exception
*/
protected function importUsers(SymfonyStyle $io, $password, $users)
protected function importUsers(SymfonyStyle $io, $password, $users, $rates)
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
@@ -489,6 +512,34 @@ class KimaiImporterCommand extends Command
throw new \Exception('Failed to validate user: ' . $user->getUsername());
}
// find and migrate user preferences
$prefsToImport = ['ui.lang' => 'language', 'timezone' => 'timezone'];
$preferences = $this->fetchAllFromImport('preferences', ['userID' => $oldUser['userID']]);
foreach ($preferences as $pref) {
$key = $pref['option'];
if (!array_key_exists($key, $prefsToImport)) {
continue;
}
$newPref = new UserPreference();
$newPref
->setName($prefsToImport[$key])
->setValue($pref['value']);
$user->addPreference($newPref);
}
// find hourly rate
foreach ($rates as $ratesRow) {
if ($ratesRow['userID'] === $oldUser['userID'] && $ratesRow['activityID'] === null && $ratesRow['projectID'] === null) {
$newPref = new UserPreference();
$newPref
->setName(UserPreference::HOURLY_RATE)
->setValue($ratesRow['rate']);
$user->addPreference($newPref);
}
}
try {
$entityManager->persist($user);
$entityManager->flush();
@@ -535,10 +586,11 @@ class KimaiImporterCommand extends Command
* @param SymfonyStyle $io
* @param array $customers
* @param string $country
* @param string $currency
* @return int
* @throws \Exception
*/
protected function importCustomers(SymfonyStyle $io, $customers, $country)
protected function importCustomers(SymfonyStyle $io, $customers, $country, $currency)
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
@@ -565,7 +617,8 @@ class KimaiImporterCommand extends Command
->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city'])
->setTimezone($oldCustomer['timezone'])
->setVisible($isActive)
->setCountry($country)
->setCountry(strtoupper($country))
->setCurrency(strtoupper($currency))
;
if (!$this->validateImport($io, $customer)) {
@@ -848,7 +901,7 @@ class KimaiImporterCommand extends Command
* ["description"]=> NULL
* ["comment"]=> string(36) "a work description"
* -- ["commentType"]=> string(1) "0"
* -- ["cleared"]=> string(1) "0"
* ["cleared"]=> string(1) "0"
* -- ["location"]=> string(0) ""
* -- ["trackingNumber"]=> NULL
* ["rate"]=> string(5) "50.00"
@@ -869,6 +922,7 @@ class KimaiImporterCommand extends Command
{
$counter = 0;
$activityCounter = 0;
$userCounter = 0;
$entityManager = $this->getDoctrine()->getManager();
$total = count($records);
@@ -902,12 +956,51 @@ class KimaiImporterCommand extends Command
}
if (null === $activity) {
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
$io->error('Could not import timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
continue;
}
$duration = $oldRecord['end'] - $oldRecord['start'];
// ----------------------- unknown user, damned missing data integrity in Kimai v1 -----------------------
if (!isset($this->users[$oldRecord['userID']])) {
$tempUserName = uniqid();
$tempPassword = uniqid() . uniqid();
$user = new User();
$user->setUsername($tempUserName)
->setAlias('Import: ' . $tempUserName)
->setEmail($tempUserName . '@example.com')
->setPlainPassword($tempPassword)
->setEnabled(false)
->setRoles([USER::ROLE_USER])
;
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
if (!$this->validateImport($io, $user)) {
$io->error('Found timesheet record for unknown user and failed to create user, skipping timesheet: ' . $oldRecord['timeEntryID']);
continue;
}
try {
$entityManager->persist($user);
$entityManager->flush();
if ($this->debug) {
$io->success('Created deactivated user: ' . $user->getUsername());
}
$userCounter++;
} catch (\Exception $ex) {
$io->error('Failed to create user: ' . $user->getUsername());
$io->error('Reason: ' . $ex->getMessage());
continue;
}
$this->users[$oldRecord['userID']] = $user;
}
// ----------------------- unknown user end -----------------------
$timesheet = new Timesheet();
$fixedRate = $oldRecord['fixedRate'];
@@ -928,14 +1021,30 @@ class KimaiImporterCommand extends Command
$timesheet->setRate(round($rate, 2));
}
$user = $this->users[$oldRecord['userID']];
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
$dateTimezone = new \DateTimeZone('UTC');
$begin = new \DateTime('@' . $oldRecord['start']);
$begin->setTimezone($dateTimezone);
$end = new \DateTime('@' . $oldRecord['end']);
$end->setTimezone($dateTimezone);
// ---------- workaround for localizeDates ----------
// if getBegin() is not execute first, then the dates will we re-written in validateImport() below
$timesheet->setBegin($begin)->setEnd($end)->getBegin();
// --------------------------------------------------
$timesheet
->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null))
->setUser($this->users[$oldRecord['userID']])
->setBegin(new \DateTime('@' . $oldRecord['start']))
->setEnd(new \DateTime('@' . $oldRecord['end']))
->setBegin($begin)
->setEnd($end)
->setDuration($duration)
->setActivity($activity)
->setProject($project)
->setExported(intval($oldRecord['cleared']) !== 0)
->setTimezone($timezone)
;
if (!$this->validateImport($io, $timesheet)) {
@@ -965,6 +1074,9 @@ class KimaiImporterCommand extends Command
}
$io->writeln(' (' . $counter . '/' . $total . ')');
if ($userCounter > 0) {
$io->success('Created new users during timesheet import: ' . $userCounter);
}
if ($activityCounter > 0) {
$io->success('Created new activities during timesheet import: ' . $activityCounter);
}

View File

@@ -20,16 +20,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
class RunCodeSnifferCommand extends Command
{
/**
* @var BashExecutor
*/
protected $executor;
/**
* @var string
*/
protected $rootDir = '';
/**
* @param BashExecutor $executor
* @param string $projectDirectory
*/
public function __construct($projectDirectory)
public function __construct(BashExecutor $executor, string $projectDirectory)
{
$this->executor = $executor;
$this->rootDir = realpath($projectDirectory);
parent::__construct();
}
@@ -42,7 +48,7 @@ class RunCodeSnifferCommand extends Command
$this
->setName('kimai:phpcs')
->setDescription('Run PHP_CodeSniffer to check for the projects coding style')
->addOption('fix', null, InputOption::VALUE_NONE, 'Fix all found problems (risky: modifies your files)')
->addOption('fix', null, InputOption::VALUE_NONE, 'Fix all found problems')
->addOption('checkstyle', null, InputOption::VALUE_OPTIONAL, '')
;
}
@@ -55,7 +61,6 @@ class RunCodeSnifferCommand extends Command
$io = new SymfonyStyle($input, $output);
$filename = null;
ob_start();
$args = [];
if (!$input->getOption('fix')) {
@@ -78,13 +83,11 @@ class RunCodeSnifferCommand extends Command
}
}
$exitCode = 0;
passthru($this->rootDir . '/vendor/bin/php-cs-fixer fix ' . implode(' ', $args), $exitCode);
$result = ob_get_clean();
$result = $this->executor->execute('/vendor/bin/php-cs-fixer fix ' . implode(' ', $args));
$io->write($result);
$io->write($result->getResult());
if ($exitCode > 0) {
if ($result->getExitCode() > 0) {
$io->error(
'Found problems while checking your code styles' .
(!empty($filename) ? '. Saved checkstyle data to: ' . $filename : '')

View File

@@ -9,8 +9,6 @@
namespace App\Command;
use Symfony\Component\Console\Command\Command;
/**
* Command used to run all integration tests.
*/
@@ -29,11 +27,10 @@ class RunIntegrationTestsCommand extends RunUnitTestsCommand
}
/**
* @param $directory
* @return string
*/
protected function createPhpunitCmdLine($directory)
protected function createPhpunitCmdLine()
{
return $this->rootDir . '/bin/phpunit --group integration ' . $directory;
return '/bin/phpunit --group integration ' . $this->rootDir . '/tests';
}
}

View File

@@ -19,17 +19,22 @@ use Symfony\Component\Console\Style\SymfonyStyle;
*/
class RunUnitTestsCommand extends Command
{
/**
* @var BashExecutor
*/
protected $executor;
/**
* @var string
*/
protected $rootDir;
/**
* RunCodeSnifferCommand constructor.
* @param BashExecutor $executor
* @param string $projectDirectory
*/
public function __construct($projectDirectory)
public function __construct(BashExecutor $executor, string $projectDirectory)
{
$this->executor = $executor;
$this->rootDir = realpath($projectDirectory);
parent::__construct();
}
@@ -53,36 +58,24 @@ class RunUnitTestsCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$this->executeTests($io, '/tests');
$result = $this->executor->execute($this->createPhpunitCmdLine());
$io->write($result->getResult());
if ($result->getExitCode() > 0) {
$io->error('Found problems while running tests');
return;
}
$io->success('All tests were successful');
}
/**
* @param $directory
* @return string
*/
protected function createPhpunitCmdLine($directory)
protected function createPhpunitCmdLine()
{
return $this->rootDir . '/bin/phpunit --exclude-group integration ' . $directory;
}
/**
* @param string $directory
*/
protected function executeTests(SymfonyStyle $io, $directory)
{
$directory = $this->rootDir . $directory;
$exitCode = 0;
ob_start();
passthru($this->createPhpunitCmdLine($directory), $exitCode);
$result = ob_get_clean();
$io->write($result);
if ($exitCode > 0) {
$io->error('Found problems while running tests at: ' . $directory);
} else {
$io->success('All tests performed good at: ' . $directory);
}
return '/bin/phpunit --exclude-group integration ' . $this->rootDir . '/tests';
}
}

View File

@@ -14,10 +14,14 @@ namespace App;
*/
class Constants
{
/**
* The software name
*/
public const SOFTWARE = 'Kimai 2';
/**
* The current release version
*/
public const VERSION = '0.7';
public const VERSION = '0.8';
/**
* The release name, will only change for new major version
*/

View File

@@ -37,7 +37,7 @@ class ActivityController extends AbstractController
public function recentActivitiesAction()
{
$user = $this->getUser();
$entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
$entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-1 year'));
return $this->render(
'navbar/recent-activities.html.twig',

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/about")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('system_information')")
*/
class AboutController extends AbstractController
{

View File

@@ -13,7 +13,7 @@ use App\Controller\AbstractController;
use App\Controller\TimesheetControllerTrait;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetAdminToolbarForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -78,20 +78,26 @@ class TimesheetController extends AbstractController
public function exportAction(Request $request)
{
$query = new TimesheetQuery();
$query->setResultType(TimesheetQuery::RESULT_TYPE_OBJECTS);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
}
if (null !== $query->getEnd()) {
$query->getEnd()->setTime(23, 59, 59);
}
}
// by default the current month is exported, but it can be overwritten
if (null === $query->getBegin()) {
$query->setBegin(new \DateTime('first day of this month'));
}
$query->getBegin()->setTime(0, 0, 0);
if (null === $query->getEnd()) {
$query->setEnd(new \DateTime('last day of this month'));
}
$query->getEnd()->setTime(23, 59, 59);
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
@@ -189,6 +195,7 @@ class TimesheetController extends AbstractController
'page' => $page,
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => true,
]);
}
@@ -199,11 +206,12 @@ class TimesheetController extends AbstractController
*/
protected function getToolbarForm(TimesheetQuery $query)
{
return $this->createForm(TimesheetAdminToolbarForm::class, $query, [
return $this->createForm(TimesheetToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_timesheet', [
'page' => $query->getPage(),
]),
'method' => 'GET',
'include_user' => true,
]);
}
}

View File

@@ -13,6 +13,7 @@ use App\Calendar\Service;
use App\Calendar\TimesheetEntity;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -30,13 +31,19 @@ class CalendarController extends AbstractController
* @var Service
*/
protected $calendar;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param Service $calendar
* @param UserDateTimeFactory $dateTime
*/
public function __construct(Service $calendar)
public function __construct(Service $calendar, UserDateTimeFactory $dateTime)
{
$this->calendar = $calendar;
$this->dateTime = $dateTime;
}
/**
@@ -47,7 +54,8 @@ class CalendarController extends AbstractController
{
return $this->render('calendar/user.html.twig', [
'config' => $this->calendar->getConfig(),
'google' => $this->calendar->getGoogle()
'google' => $this->calendar->getGoogle(),
'now' => $this->dateTime->createDateTime(),
]);
}

View File

@@ -0,0 +1,162 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\Timesheet;
use App\Export\ServiceExport;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\Query\ExportQuery;
use App\Repository\TimesheetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('view_export')")
*/
class ExportController extends AbstractController
{
/**
* @var TimesheetRepository
*/
protected $timesheetRepository;
/**
* @var ServiceExport
*/
protected $export;
/**
* @param TimesheetRepository $timesheet
* @param ServiceExport $export
*/
public function __construct(TimesheetRepository $timesheet, ServiceExport $export)
{
$this->timesheetRepository = $timesheet;
$this->export = $export;
}
/**
* @return ExportQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$query = new ExportQuery();
$query->setOrder(ExportQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
return $query;
}
/**
* @Route(path="/", name="export", methods={"GET", "POST"})
* @Security("is_granted('view_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function indexAction(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$entries = $this->getEntries($query);
return $this->render('export/index.html.twig', [
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
]);
}
/**
* @Route(path="/data", name="export_data", methods={"GET", "POST"})
* @Security("is_granted('create_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function export(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$type = $query->getType();
if (null === $type) {
throw $this->createNotFoundException('Missing export renderer');
}
$renderer = $this->export->getRendererById($type);
// this code should not be reached, as the query already filters invalid values
// when trying to call setType() with an unknown value
if (null === $renderer) {
throw $this->createNotFoundException('Invalid export renderer');
}
$entries = $this->getEntries($query);
return $renderer->render($entries, $query);
}
/**
* @param ExportQuery $query
* @return Timesheet[]
*/
protected function getEntries(ExportQuery $query)
{
$query->setResultType(ExportQuery::RESULT_TYPE_QUERYBUILDER);
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->timesheetRepository->findByQuery($query);
return $queryBuilder->getQuery()->getResult();
}
/**
* @param ExportQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(ExportQuery $query)
{
return $this->createForm(ExportToolbarForm::class, $query, [
'action' => $this->generateUrl('export', []),
'method' => 'POST',
'attr' => [
'id' => 'export-form'
]
]);
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Entity\User;
use App\Form\Type\InitialViewType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@@ -30,8 +31,9 @@ class HomepageController extends AbstractController
*/
public function indexAction(Request $request)
{
// make me configurable via UserPreference
$route = 'timesheet';
/** @var User $user */
$user = $this->getUser();
$route = $user->getPreferenceValue('login.initial_view', InitialViewType::DEFAULT_VIEW);
/** @var User $user */
$user = $this->getUser();

View File

@@ -168,6 +168,13 @@ class InvoiceController extends AbstractController
}
$query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER);
if (null === $query->getBegin()) {
$query->setBegin(new \DateTime('first day of this month'));
}
if (null === $query->getEnd()) {
$query->setEnd(new \DateTime('last day of this month'));
}
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);

View File

@@ -18,6 +18,7 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -45,16 +46,25 @@ class ProfileController extends AbstractController
protected $encoder;
/**
* @param UserPasswordEncoderInterface $encoder
* @var UserDateTimeFactory
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
protected $dateTime;
/**
* @param UserPasswordEncoderInterface $encoder
* @param EventDispatcherInterface $dispatcher
* @param UserDateTimeFactory $dateTime
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, UserDateTimeFactory $dateTime)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
$this->dateTime = $dateTime;
}
/**
* @Route(path="/", name="fos_user_profile_show", methods={"GET"})
* @Route(path="/", name="my_profile", methods={"GET"})
*/
public function profileAction()
{
@@ -251,6 +261,7 @@ class ProfileController extends AbstractController
'user' => $user,
'stats' => $userStats,
'years' => $monthlyStats,
'local_time' => $this->dateTime->createDateTime(),
'forms' => []
];

View File

@@ -29,14 +29,6 @@ class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* @param int $hardLimit
*/
public function __construct(int $hardLimit)
{
$this->setHardLimit($hardLimit);
}
/**
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
@@ -75,6 +67,7 @@ class TimesheetController extends AbstractController
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
'showSummary' => $this->getUser()->getPreferenceValue('timesheet.daily_stats', false),
]);
}
@@ -88,20 +81,27 @@ class TimesheetController extends AbstractController
public function exportAction(Request $request)
{
$query = new TimesheetQuery();
$query->setResultType(TimesheetQuery::RESULT_TYPE_OBJECTS);
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var TimesheetQuery $query */
$query = $form->getData();
if (null !== $query->getBegin()) {
$query->getBegin()->setTime(0, 0, 0);
}
if (null !== $query->getEnd()) {
$query->getEnd()->setTime(23, 59, 59);
}
}
// by default the current month is exported, but it can be overwritten
if (null === $query->getBegin()) {
$query->setBegin(new \DateTime('first day of this month'));
}
$query->getBegin()->setTime(0, 0, 0);
if (null === $query->getEnd()) {
$query->setEnd(new \DateTime('last day of this month'));
}
$query->getEnd()->setTime(23, 59, 59);
// user timesheet always export for the session user
$query->setUser($this->getUser());
/* @var $entries Pagerfanta */
@@ -154,7 +154,7 @@ class TimesheetController extends AbstractController
try {
$entry = new Timesheet();
$entry
->setBegin(new \DateTime())
->setBegin($this->dateTime->createDateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
@@ -165,11 +165,12 @@ class TimesheetController extends AbstractController
if (count($errors) > 0) {
$this->flashError('timesheet.start.error', ['%reason%' => $errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()]);
} else {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$this->stopActiveEntries($user);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
@@ -268,6 +269,7 @@ class TimesheetController extends AbstractController
'origin' => $redirectRoute,
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
]);
}

View File

@@ -12,6 +12,7 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -27,6 +28,21 @@ trait TimesheetControllerTrait
*/
private $hardLimit = 1;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param UserDateTimeFactory $dateTime
* @param int $hardLimit
*/
public function __construct(UserDateTimeFactory $dateTime, int $hardLimit)
{
$this->dateTime = $dateTime;
$this->setHardLimit($hardLimit);
}
/**
* @param int $hardLimit
*/
@@ -118,7 +134,7 @@ trait TimesheetControllerTrait
{
$entry = new Timesheet();
$entry->setUser($this->getUser());
$entry->setBegin(new \DateTime());
$entry->setBegin($this->dateTime->createDateTime());
$start = $request->get('begin');
if ($start !== null) {
@@ -172,7 +188,9 @@ trait TimesheetControllerTrait
$entityManager = $this->getDoctrine()->getManager();
try {
$this->stopActiveEntries($entry->getUser());
if (null === $entry->getEnd()) {
$this->stopActiveEntries($entry->getUser());
}
$entityManager->persist($entry);
$entityManager->flush();
@@ -252,7 +270,7 @@ trait TimesheetControllerTrait
/**
* Get a user from the Security Token Storage.
*
* @return mixed
* @return User
* @throws \LogicException If SecurityBundle is not available
*/
abstract protected function getUser();

View File

@@ -114,6 +114,7 @@ class AppExtension extends Extension implements PrependExtensionInterface
*/
protected function createTimesheetParameter(array $config, ContainerBuilder $container)
{
$container->setParameter('kimai.timesheet.rules', $config['rules']);
$container->setParameter('kimai.timesheet.rates', $config['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);

View File

@@ -0,0 +1,41 @@
<?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\DependencyInjection\Compiler;
use App\Export\ServiceExport;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all dependencies to the ExportService.
*/
class ExportServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceExport::class)) {
return;
}
$definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
}
}

View File

@@ -113,28 +113,35 @@ class Configuration implements ConfigurationInterface
->end()
->arrayNode('active_entries')
->addDefaultsIfNotSet()
->children()
->integerNode('soft_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The soft_limit must be at least 1')
->end()
->children()
->integerNode('soft_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The soft_limit must be at least 1')
->end()
->integerNode('hard_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The hard_limit must be at least 1')
->end()
->end()
->integerNode('hard_limit')
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('The hard_limit must be at least 1')
->end()
->end()
->end()
->end()
->arrayNode('rules')
->addDefaultsIfNotSet()
->children()
->booleanNode('allow_future_times')
->defaultTrue()
->end()
->end()
->end()
->end()
;
@@ -172,9 +179,13 @@ class Configuration implements ConfigurationInterface
$node
->arrayPrototype()
->children()
->scalarNode('date_short')->end()
->scalarNode('duration')->end()
->scalarNode('duration_short')->end()
->scalarNode('date_time_type')->defaultValue('yyyy-MM-dd HH:mm')->end() // for DateTimeType
->scalarNode('date_time_picker')->defaultValue('YYYY-MM-DD HH:mm')->end() // for DateTimeType JS component
->scalarNode('date_type')->defaultValue('yyyy-MM-dd')->end() // for DateType
->scalarNode('date_picker')->defaultValue('YYYY-MM-DD')->end() // for DateType JS component
->scalarNode('date')->defaultValue('Y-m-d')->end() // for display via twig
->scalarNode('date_time')->defaultValue('m-d H:i')->end() // for display via twig
->scalarNode('duration')->defaultValue('%%h:%%m h')->end() // for display via twig
->end()
->end()
;

View File

@@ -0,0 +1,74 @@
<?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\Doctrine;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\DateTimeType;
class UTCDateTimeType extends DateTimeType
{
/**
* @var \DateTimeZone
*/
private static $utc;
/**
* @param mixed $value
* @param AbstractPlatform $platform
* @return mixed|string
* @throws ConversionException
*/
public function convertToDatabaseValue($value, AbstractPlatform $platform)
{
if ($value instanceof \DateTime) {
$value->setTimezone(self::getUtc());
}
return parent::convertToDatabaseValue($value, $platform);
}
/**
* @return \DateTimeZone
*/
public static function getUtc()
{
return self::$utc ? self::$utc : self::$utc = new \DateTimeZone('UTC');
}
/**
* @param mixed $value
* @param AbstractPlatform $platform
* @return bool|\DateTime|false|mixed
* @throws ConversionException
*/
public function convertToPHPValue($value, AbstractPlatform $platform)
{
if (null === $value || $value instanceof \DateTime) {
return $value;
}
$converted = \DateTime::createFromFormat(
$platform->getDateTimeFormatString(),
$value,
self::getUtc()
);
if (!$converted) {
throw ConversionException::conversionFailedFormat(
$value,
$this->getName(),
$platform->getDateTimeFormatString()
);
}
return $converted;
}
}

View File

@@ -11,7 +11,6 @@ namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Timesheet entity.
@@ -25,7 +24,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* )
* @ORM\Entity(repositoryClass="App\Repository\TimesheetRepository")
* @ORM\HasLifecycleCallbacks()
* @Assert\Callback("validate")
* @App\Validator\Constraints\Timesheet
*/
class Timesheet
{
@@ -53,6 +52,18 @@ class Timesheet
*/
private $end;
/**
* @var string
*
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
*/
private $timezone;
/**
* @var bool
*/
private $localized = false;
/**
* @var int
*
@@ -119,6 +130,14 @@ class Timesheet
*/
private $hourlyRate = null;
/**
* @var bool
*
* @ORM\Column(name="exported", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $exported = false;
/**
* Get entry id
*
@@ -129,11 +148,34 @@ class Timesheet
return $this->id;
}
/**
* 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->begin) {
$this->begin->setTimeZone(new \DateTimeZone($this->timezone));
}
if (null !== $this->end) {
$this->end->setTimeZone(new \DateTimeZone($this->timezone));
}
$this->localized = true;
}
/**
* @return \DateTime
*/
public function getBegin()
{
$this->localizeDates();
return $this->begin;
}
@@ -141,9 +183,10 @@ class Timesheet
* @param \DateTime $begin
* @return Timesheet
*/
public function setBegin($begin)
public function setBegin(\DateTime $begin)
{
$this->begin = $begin;
$this->timezone = $begin->getTimezone()->getName();
return $this;
}
@@ -153,6 +196,8 @@ class Timesheet
*/
public function getEnd()
{
$this->localizeDates();
return $this->end;
}
@@ -160,21 +205,21 @@ class Timesheet
* @param \DateTime $end
* @return Timesheet
*/
public function setEnd($end)
public function setEnd(?\DateTime $end)
{
$this->end = $end;
if (null === $end) {
$this->duration = 0;
$this->rate = 0;
} else {
$this->timezone = $end->getTimezone()->getName();
}
return $this;
}
/**
* Set duration
*
* @param int $duration
* @return Timesheet
*/
@@ -186,8 +231,7 @@ class Timesheet
}
/**
* Get duration
* Do not rely on the results of this method for active records.
* Do not rely on the results of this method for running records.
*
* @return int
*/
@@ -197,8 +241,6 @@ class Timesheet
}
/**
* Set user
*
* @param User $user
* @return Timesheet
*/
@@ -210,8 +252,6 @@ class Timesheet
}
/**
* Get user
*
* @return User
*/
public function getUser()
@@ -220,8 +260,6 @@ class Timesheet
}
/**
* Set activity
*
* @param Activity $activity
* @return Timesheet
*/
@@ -233,8 +271,6 @@ class Timesheet
}
/**
* Get Activity
*
* @return Activity
*/
public function getActivity()
@@ -346,74 +382,43 @@ class Timesheet
}
/**
* @param ExecutionContextInterface $context
* @param $payload
* @return bool
*/
public function validate(ExecutionContextInterface $context, $payload)
public function isExported(): bool
{
if (null === ($activity = $this->getActivity())) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
return $this->exported;
}
if (null === ($project = $this->getProject())) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
/**
* @param bool $exported
* @return Timesheet
*/
public function setExported(bool $exported)
{
$this->exported = $exported;
if (null !== $activity && null !== $project) {
if (null !== $activity->getProject() && $activity->getProject() !== $project) {
$context->buildViolation('Project mismatch, project specific activity and timesheet project are different.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
return $this;
}
if (null === $this->getEnd() && $activity->getVisible() === false) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
/**
* @return string
*/
public function getTimezone(): string
{
return $this->timezone;
}
if (null === $this->getEnd() && $project->getVisible() === false) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
/**
* BE WARNED: this method should NOT be used programmatically, there is very likely no reason for it!
*
* @deprecated since it was introduced, only meant for the initial migration. Will be removed with 1.0.
* @param string $timezone
* @return Timesheet
*/
public function setTimezone(string $timezone)
{
$this->timezone = $timezone;
if (null === $this->getEnd() && $project->getCustomer()->getVisible() === false) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->addViolation();
}
}
if (null === $this->getBegin()) {
$context->buildViolation('You must submit a begin date.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
} else {
if (null !== $this->getBegin() && null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('End date must not be earlier then start date.')
->atPath('end')
->setTranslationDomain('validators')
->addViolation();
}
if (time() < $this->getBegin()->getTimestamp()) {
$context->buildViolation('The begin date cannot be in the future.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
}
}
return $this;
}
}

View File

@@ -70,6 +70,12 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
);
}
if ($auth->isGranted('view_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], 'fas fa-file-export')
);
}
}
/**

View File

@@ -16,9 +16,6 @@ use KevinPapst\AdminLTEBundle\Model\UserModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Class NavbarShowUserSubscriber
*/
class NavbarShowUserSubscriber implements EventSubscriberInterface
{
/**
@@ -27,7 +24,6 @@ class NavbarShowUserSubscriber implements EventSubscriberInterface
protected $storage;
/**
* NavbarShowUserListener constructor.
* @param TokenStorageInterface $tokenStorage
*/
public function __construct(TokenStorageInterface $tokenStorage)
@@ -64,7 +60,7 @@ class NavbarShowUserSubscriber implements EventSubscriberInterface
->setIsOnline(true)
->setTitle($myUser->getTitle())
->setAvatar($myUser->getAvatar())
->setMemberSince(new \DateTime());
->setMemberSince($myUser->getRegisteredAt());
$event->setUser($user);
}

View File

@@ -0,0 +1,77 @@
<?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\EventSubscriber;
use App\Entity\User;
use KevinPapst\AdminLTEBundle\Event\ShowUserEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
class TimezoneSubscriber implements EventSubscriberInterface
{
/**
* @var TokenStorageInterface
*/
protected $storage;
/**
* @param TokenStorageInterface $tokenStorage
*/
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->storage = $tokenStorage;
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['setTimezone', 100],
];
}
/**
* @param ShowUserEvent $event
*/
public function setTimezone(GetResponseEvent $event)
{
if (!$this->canHandleEvent()) {
return;
}
/* @var $user User */
$user = $this->storage->getToken()->getUser();
$timezone = $user->getPreferenceValue('timezone', date_default_timezone_get());
date_default_timezone_set($timezone);
}
/**
* @return bool
*/
protected function canHandleEvent(): bool
{
if (null === $this->storage->getToken()) {
return false;
}
/* @var $user User */
$user = $this->storage->getToken()->getUser();
if (null === $user) {
return false;
}
return ($user instanceof User);
}
}

View File

@@ -14,12 +14,14 @@ use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Event\UserPreferenceEvent;
use App\Form\Type\CalendarViewType;
use App\Form\Type\InitialViewType;
use App\Form\Type\LanguageType;
use App\Form\Type\SkinType;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Validator\Constraints\Range;
@@ -75,13 +77,6 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
$enableHourlyRate = true;
}
/*
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
*/
return [
(new UserPreference())
->setName(UserPreference::HOURLY_RATE)
@@ -90,6 +85,11 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setEnabled($enableHourlyRate)
->addConstraint(new Range(['min' => 0])),
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
(new UserPreference())
->setName('language')
->setValue('en')
@@ -124,6 +124,16 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)
->setType(CalendarViewType::class),
(new UserPreference())
->setName('login.initial_view')
->setValue(InitialViewType::DEFAULT_VIEW)
->setType(InitialViewType::class),
(new UserPreference())
->setName('timesheet.daily_stats')
->setValue(false)
->setType(CheckboxType::class),
];
}

View File

@@ -0,0 +1,215 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractSpreadsheetRenderer
{
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var Extensions
*/
protected $extension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
* @param DateExtensions $dateExtension
* @param Extensions $extensions
*/
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
{
return $this->dateExtension->dateShort($date) . ' ' . $this->dateExtension->time($date);
}
/**
* @param $amount
* @return mixed
*/
protected function getFormattedMoney($amount, $currency)
{
return $this->extension->money($amount, $currency);
}
/**
* @param Timesheet $timesheet
* @return string
*/
protected function getUsername(Timesheet $timesheet)
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
}
return $timesheet->getUser()->getUsername();
}
/**
* @param $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
{
return $this->extension->duration($seconds);
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourly_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixed_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate'));
$entryHeaderRow = $recordsHeaderRow + 1;
$durationTotal = 0;
$currency = false;
$rateTotal = 0;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$durationTotal += $timesheet->getDuration();
$rateTotal += $timesheet->getRate();
if ($currency === false) {
$currency = $timesheet->getProject()->getCustomer()->getCurrency();
}
if ($currency !== $timesheet->getProject()->getCustomer()->getCurrency()) {
$currency = null;
}
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getBegin()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getEnd()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getUsername($timesheet));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getActivity()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency));
$entryHeaderRow++;
}
$sheet->setCellValueByColumnAndRow(11, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->setCellValueByColumnAndRow(12, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
return $spreadsheet;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query);
$filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension());
}
/**
* @return string
*/
abstract public function getFileExtension(): string;
/**
* @param mixed $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse($file, $filename): Response
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
/**
* @return string
*/
abstract protected function getContentType(): string;
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'csv';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'csv';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'csv';
}
}

View File

@@ -0,0 +1,79 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
class HtmlRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets,
'query' => $query,
'summaries' => $this->calculateSummary($timesheets),
]);
$response = new Response();
$response->setContent($content);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'html';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'print';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'print';
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.ods';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'ods';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'ods';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'ods';
}
}

View File

@@ -0,0 +1,102 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PDFRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param \Twig_Environment $twig
* @param UserDateTimeFactory $dateTime
*/
public function __construct(\Twig_Environment $twig, UserDateTimeFactory $dateTime)
{
$this->twig = $twig;
$this->dateTime = $dateTime;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Mpdf\MpdfException
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/pdf.html.twig', [
'entries' => $timesheets,
'query' => $query,
'now' => $this->dateTime->createDateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);
//return new Response($content);
$mpdf = new Mpdf();
$mpdf->WriteHTML($content);
$content = $mpdf->Output('test', Destination::STRING_RETURN);
$response = new Response($content);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf');
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'pdf';
}
}

View File

@@ -0,0 +1,43 @@
<?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\Export\Renderer;
use App\Entity\Timesheet;
trait RendererTrait
{
/**
* @param Timesheet[] $timesheets
* @return array
*/
protected function calculateSummary(array $timesheets)
{
$summary = [];
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId();
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(),
'project' => $timesheet->getProject()->getName(),
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
$summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $timesheet->getDuration();
}
asort($summary);
return $summary;
}
}

View File

@@ -0,0 +1,71 @@
<?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\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.xlsx';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'xlsx';
}
}

View File

@@ -0,0 +1,39 @@
<?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\Export;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
interface RendererInterface
{
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $timesheets, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
/**
* @return string
*/
public function getIcon(): string;
/**
* @return string
*/
public function getTitle(): string;
}

View File

@@ -0,0 +1,54 @@
<?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\Export;
class ServiceExport
{
/**
* @var RendererInterface[]
*/
protected $renderer = [];
/**
* @param RendererInterface $renderer
* @return $this
*/
public function addRenderer(RendererInterface $renderer)
{
$this->renderer[] = $renderer;
return $this;
}
/**
* Returns an array of export renderer.
*
* @return RendererInterface[]
*/
public function getRenderer()
{
return $this->renderer;
}
/**
* @param string $id
* @return RendererInterface|null
*/
public function getRendererById(string $id)
{
foreach ($this->renderer as $renderer) {
if ($renderer->getId() === $id) {
return $renderer;
}
}
return null;
}
}

View File

@@ -0,0 +1,62 @@
<?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\Model;
use DateTime;
class DateRange
{
/**
* @var DateTime
*/
protected $begin;
/**
* @var DateTime
*/
protected $end;
/**
* @return DateTime
*/
public function getBegin(): ?DateTime
{
return $this->begin;
}
/**
* @param DateTime $begin
* @return DateRange
*/
public function setBegin(DateTime $begin)
{
$this->begin = $begin;
return $this;
}
/**
* @return DateTime
*/
public function getEnd(): ?DateTime
{
return $this->end;
}
/**
* @param DateTime $end
* @return DateRange
*/
public function setEnd(DateTime $end)
{
$this->end = $end;
return $this;
}
}

View File

@@ -12,14 +12,16 @@ namespace App\Form;
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\ProjectType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Timesheet\UserDateTimeFactory;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -47,15 +49,22 @@ class TimesheetEditForm extends AbstractType
*/
private $durationOnly = false;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param CustomerRepository $customer
* @param ProjectRepository $project
* @param UserDateTimeFactory $dateTime
* @param bool $durationOnly
*/
public function __construct(CustomerRepository $customer, ProjectRepository $project, bool $durationOnly)
public function __construct(CustomerRepository $customer, ProjectRepository $project, UserDateTimeFactory $dateTime, bool $durationOnly)
{
$this->customers = $customer;
$this->projects = $project;
$this->dateTime = $dateTime;
$this->durationOnly = $durationOnly;
}
@@ -69,6 +78,7 @@ class TimesheetEditForm extends AbstractType
$customer = null;
$currency = false;
$end = null;
$begin = null;
if (isset($options['data'])) {
/** @var Timesheet $entry */
@@ -86,17 +96,21 @@ class TimesheetEditForm extends AbstractType
$currency = $customer->getCurrency();
}
$begin = $entry->getBegin();
$end = $entry->getEnd();
}
$timezone = $this->dateTime->getTimezone()->getName();
if (null !== $begin) {
$timezone = $begin->getTimezone()->getName();
}
if (null === $end || !$options['duration_only']) {
$builder->add('begin', DateTimeType::class, [
$builder->add('begin', DateTimePickerType::class, [
'label' => 'label.begin',
'widget' => 'single_text',
'html5' => false,
'format' => 'yyyy-MM-dd HH:mm',
'with_seconds' => false,
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on', 'placeholder' => 'yyyy-MM-dd HH:mm'],
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
}
@@ -105,14 +119,11 @@ class TimesheetEditForm extends AbstractType
'required' => false,
]);
} else {
$builder->add('end', DateTimeType::class, [
'label' => 'label.end',
'widget' => 'single_text',
$builder->add('end', DateTimePickerType::class, [
'label' => 'label.begin',
'model_timezone' => $timezone,
'view_timezone' => $timezone,
'required' => false,
'html5' => false,
'format' => 'yyyy-MM-dd HH:mm',
'with_seconds' => false,
'attr' => ['autocomplete' => 'off', 'data-datetimepicker' => 'on', 'placeholder' => 'yyyy-MM-dd HH:mm'],
]);
}
@@ -139,9 +150,7 @@ class TimesheetEditForm extends AbstractType
$projectOptions['group_by'] = null;
}
if ($this->projects->countProject(true) > 1) {
$projectOptions['placeholder'] = null;
} else {
if ($this->projects->countProject(true) <= 1) {
$projectOptions['group_by'] = null;
}
@@ -150,15 +159,16 @@ class TimesheetEditForm extends AbstractType
'project',
ProjectType::class,
array_merge($projectOptions, [
'activity_enabled' => true,
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer);
},
'placeholder' => '',
'activity_enabled' => true,
// documentation is for NelmioApiDocBundle
'documentation' => [
'type' => 'integer',
'description' => 'Project ID',
],
'query_builder' => function (ProjectRepository $repo) use ($project, $customer) {
return $repo->builderForEntityType($project, $customer);
},
])
);
@@ -172,6 +182,7 @@ class TimesheetEditForm extends AbstractType
}
$event->getForm()->add('project', ProjectType::class, [
'placeholder' => '',
'activity_enabled' => true,
'group_by' => null,
'query_builder' => function (ProjectRepository $repo) use ($data, $project) {
@@ -184,7 +195,7 @@ class TimesheetEditForm extends AbstractType
$builder
->add('activity', ActivityType::class, [
// documentation is for NelmioApiDocBundle
'placeholder' => null,
'placeholder' => '',
'documentation' => [
'type' => 'integer',
'description' => 'Activity ID',
@@ -205,7 +216,7 @@ class TimesheetEditForm extends AbstractType
}
$event->getForm()->add('activity', ActivityType::class, [
'placeholder' => null,
'placeholder' => '',
'query_builder' => function (ActivityRepository $repo) use ($data, $activity) {
return $repo->builderForEntityType($activity, $data['project']);
},
@@ -237,6 +248,12 @@ class TimesheetEditForm extends AbstractType
if ($options['include_user']) {
$builder->add('user', UserType::class);
}
if ($options['include_exported']) {
$builder->add('exported', YesNoType::class, [
'label' => 'label.exported'
]);
}
}
/**
@@ -251,6 +268,7 @@ class TimesheetEditForm extends AbstractType
'csrf_token_id' => 'timesheet_edit',
'duration_only' => $this->durationOnly,
'include_user' => false,
'include_exported' => false,
'include_rate' => true,
'docu_chapter' => 'timesheet',
'method' => 'POST',

View File

@@ -10,9 +10,8 @@
namespace App\Form\Toolbar;
use App\Form\Type\ActivityType;
use App\Form\Type\BeginDateType;
use App\Form\Type\CustomerType;
use App\Form\Type\EndDateType;
use App\Form\Type\DateRangeType;
use App\Form\Type\PageSizeType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserRoleType;
@@ -115,20 +114,11 @@ abstract class AbstractToolbarForm extends AbstractType
/**
* @param FormBuilderInterface $builder
*/
protected function addStartDateChoice(FormBuilderInterface $builder)
protected function addDateRangeChoice(FormBuilderInterface $builder, $allowEmpty = true)
{
$builder->add('begin', BeginDateType::class, [
'required' => false,
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addEndDateChoice(FormBuilderInterface $builder)
{
$builder->add('end', EndDateType::class, [
$builder->add('daterange', DateRangeType::class, [
'required' => false,
'allow_empty' => $allowEmpty,
]);
}

View File

@@ -0,0 +1,73 @@
<?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\Toolbar;
use App\Repository\Query\ExportQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for exports.
*/
class ExportToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addExportStateChoice($builder);
$this->addUserChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addExportType($builder);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportType(FormBuilderInterface $builder)
{
$builder->add('type', HiddenType::class, []);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportStateChoice(FormBuilderInterface $builder)
{
$builder->add('exported', ChoiceType::class, [
'label' => 'label.exported',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.exported' => TimesheetQuery::STATE_EXPORTED,
'entryState.not_exported' => TimesheetQuery::STATE_NOT_EXPORTED
],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ExportQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -26,8 +26,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
{
$this->addTemplateChoice($builder);
$this->addUserChoice($builder);
$this->addStartDateChoice($builder);
$this->addEndDateChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);

View File

@@ -1,34 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use Symfony\Component\Form\FormBuilderInterface;
/**
* Defines the form used for filtering the admin timesheet.
*/
class TimesheetAdminToolbarForm extends TimesheetToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addTimesheetStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addUserChoice($builder);
$this->addStartDateChoice($builder);
$this->addEndDateChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addHiddenPagination($builder);
}
}

View File

@@ -26,8 +26,10 @@ class TimesheetToolbarForm extends AbstractToolbarForm
{
$this->addTimesheetStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addStartDateChoice($builder);
$this->addEndDateChoice($builder);
if ($options['include_user']) {
$this->addUserChoice($builder);
}
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
@@ -48,7 +50,6 @@ class TimesheetToolbarForm extends AbstractToolbarForm
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}
@@ -60,6 +61,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
$resolver->setDefaults([
'data_class' => TimesheetQuery::class,
'csrf_protection' => false,
'include_user' => false,
]);
}
}

View File

@@ -75,7 +75,6 @@ class ActivityType extends AbstractType
'query_builder' => function (ActivityRepository $repo) {
return $repo->builderForEntityType();
},
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
}

View File

@@ -1,50 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the begin-date input field.
*/
class BeginDateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.begin',
'widget' => 'single_text',
'html5' => false,
'format' => DateType::HTML5_FORMAT,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'data-datepicker' => 'on',
'placeholder' => $options['format'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateType::class;
}
}

View File

@@ -36,7 +36,6 @@ class CustomerType extends AbstractType
},
'project_enabled' => false,
'project_visibility' => ProjectQuery::SHOW_VISIBLE,
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
$resolver->setDefault('api_data', function (Options $options) {

View File

@@ -0,0 +1,78 @@
<?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\Type;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the date input fields.
*/
class DatePickerType extends AbstractType
{
/**
* @var LocaleSettings
*/
protected $localeSettings;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param LocaleSettings $localeSettings
* @param UserDateTimeFactory $dateTime
*/
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
{
$this->localeSettings = $localeSettings;
$this->dateTime = $dateTime;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$pickerFormat = $this->localeSettings->getDatePickerFormat();
$dateFormat = $this->localeSettings->getDateTypeFormat();
$timezone = $this->dateTime->getTimezone()->getName();
$resolver->setDefaults([
'widget' => 'single_text',
'html5' => false,
'format' => $dateFormat,
'format_picker' => $pickerFormat,
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'placeholder' => $options['format'],
'data-format' => $options['format_picker'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateType::class;
}
}

View File

@@ -0,0 +1,194 @@
<?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\Type;
use App\Form\Model\DateRange;
use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select a date range
*/
class DateRangeType extends AbstractType
{
public const DATE_SPACER = ' - ';
/**
* @var LocaleSettings
*/
protected $localeSettings;
/**
* @param LocaleSettings $localeSettings
*/
public function __construct(LocaleSettings $localeSettings)
{
$this->localeSettings = $localeSettings;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$pickerFormat = $this->localeSettings->getDatePickerFormat();
$dateFormat = $this->localeSettings->getDateFormat();
$resolver->setDefaults([
'model_timezone' => null,
'view_timezone' => null,
'label' => 'label.daterange',
'format' => $dateFormat,
'separator' => self::DATE_SPACER,
'format_picker' => $pickerFormat,
'allow_empty' => true,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'placeholder' => $options['format_picker'] . $options['separator'] . $options['format_picker'],
'data-format' => $options['format_picker'],
'data-separator' => $options['separator'],
];
});
}
/**
* A better way would be to use the Intl NumberFormatter, but if that is not available
* and the Symfony polyfill is used, this method would not work properly.
*
* @param $string
* @return string
*/
protected function convertArabicPersian($string)
{
return strtr(
$string,
[
'۰' => '0',
'۱' => '1',
'۲' => '2',
'۳' => '3',
'۴' => '4',
'۵' => '5',
'۶' => '6',
'۷' => '7',
'۸' => '8',
'۹' => '9',
'٠' => '0',
'١' => '1',
'٢' => '2',
'٣' => '3',
'٤' => '4',
'٥' => '5',
'٦' => '6',
'٧' => '7',
'٨' => '8',
'٩' => '9'
]
);
}
protected function formatToPattern(string $format, string $separator)
{
$format = preg_quote($format, '/');
$pattern = str_replace('d', '[0-9]{2}', $format);
$pattern = str_replace('m', '[0-9]{2}', $pattern);
$pattern = str_replace('Y', '[0-9]{4}', $pattern);
return '/^' . $pattern . $separator . $pattern . '$/';
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$formatDate = $options['format'];
$separator = $options['separator'];
$allowEmpty = $options['allow_empty'];
$pattern = $this->formatToPattern($formatDate, $separator);
$builder->addModelTransformer(new CallbackTransformer(
function (DateRange $range) use ($formatDate, $separator) {
if (null === $range->getBegin()) {
return '';
}
$display = $range->getBegin()->format($formatDate);
if (null !== $range->getEnd()) {
$display .= $separator . $range->getEnd()->format($formatDate);
}
return $display;
},
function ($dates) use ($formatDate, $pattern, $separator, $allowEmpty) {
$range = new DateRange();
if (empty($dates) && $allowEmpty) {
return $range;
}
$dates = $this->convertArabicPersian($dates);
if (preg_match($pattern, $dates) !== 1) {
throw new TransformationFailedException('Invalid date range given');
}
$values = explode($separator, $dates);
if (count($values) !== 2) {
throw new TransformationFailedException('Invalid date range given');
}
$begin = \DateTime::createFromFormat($formatDate, $values[0]);
if ($begin === false) {
throw new TransformationFailedException('Invalid begin date given');
}
$range->setBegin($begin);
$end = \DateTime::createFromFormat($formatDate, $values[1]);
if ($end === false) {
throw new TransformationFailedException('Invalid end date given');
}
$range->setEnd($end);
if ($begin->getTimestamp() > $end->getTimestamp()) {
throw new TransformationFailedException('Begin date must be before end date');
}
return $range;
}
));
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return TextType::class;
}
/**
* {@inheritdoc}
*/
public function getBlockPrefix()
{
return 'daterange';
}
}

View File

@@ -0,0 +1,80 @@
<?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\Type;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\LocaleSettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the date-time input fields.
*/
class DateTimePickerType extends AbstractType
{
/**
* @var LocaleSettings
*/
protected $localeSettings;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @param LocaleSettings $localeSettings
*/
public function __construct(LocaleSettings $localeSettings, UserDateTimeFactory $dateTime)
{
$this->localeSettings = $localeSettings;
$this->dateTime = $dateTime;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$dateTimePicker = $this->localeSettings->getDateTimePickerFormat();
$dateTimeFormat = $this->localeSettings->getDateTimeTypeFormat();
$timezone = $this->dateTime->getTimezone()->getName();
$resolver->setDefaults([
'label' => 'label.begin',
'widget' => 'single_text',
'html5' => false,
'format' => $dateTimeFormat,
'format_picker' => $dateTimePicker,
'with_seconds' => false,
'model_timezone' => $timezone,
'view_timezone' => $timezone,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'data-datetimepicker' => 'on',
'autocomplete' => 'off',
'placeholder' => $options['format'],
'data-format' => $options['format_picker'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateTimeType::class;
}
}

View File

@@ -1,50 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to display the end-date input field.
*/
class EndDateType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.end',
'widget' => 'single_text',
'html5' => false,
'format' => DateType::HTML5_FORMAT,
]);
$resolver->setDefault('attr', function (Options $options) {
return [
'autocomplete' => 'off',
'data-datepicker' => 'on',
'placeholder' => $options['format'],
];
});
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return DateType::class;
}
}

View File

@@ -0,0 +1,89 @@
<?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\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* Custom form field type to select the initial view, where the user should be redirected to after login.
*/
class InitialViewType extends AbstractType
{
public const DEFAULT_VIEW = 'timesheet';
public const ALLOWED_VIEWS = [
'dashboard' => 'menu.homepage',
'timesheet' => 'menu.timesheet',
'calendar' => 'calendar.title',
'my_profile' => 'profile.title',
'admin_timesheet' => 'menu.admin_timesheet',
'invoice' => 'menu.invoice',
'admin_user' => 'menu.admin_user',
'admin_customer' => 'menu.admin_customer',
'admin_project' => 'menu.admin_project',
'admin_activity' => 'menu.admin_activity',
];
protected const ROUTE_PERMISSION = [
'dashboard' => 'menu.homepage',
'timesheet' => 'view_own_timesheet',
'calendar' => 'view_own_timesheet',
'my_profile' => 'view_own_profile',
'admin_timesheet' => 'view_other_timesheet',
'invoice' => 'view_invoice',
'admin_user' => 'view_user',
'admin_customer' => 'view_customer',
'admin_project' => 'view_project',
'admin_activity' => 'view_activity',
];
/**
* @var AuthorizationCheckerInterface
*/
protected $voter;
/**
* @param AuthorizationCheckerInterface $voter
*/
public function __construct(AuthorizationCheckerInterface $voter)
{
$this->voter = $voter;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$choices = [];
foreach (self::ROUTE_PERMISSION as $route => $permission) {
if ($this->voter->isGranted($permission)) {
$name = self::ALLOWED_VIEWS[$route];
$choices[$name] = $route;
}
}
$resolver->setDefaults([
'required' => true,
'choices' => $choices,
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -61,7 +61,6 @@ class ProjectType extends AbstractType
},
'activity_enabled' => false,
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
//'attr' => ['class' => 'selectpicker', 'data-size' => 10, 'data-live-search' => true, 'data-width' => '100%']
]);
$resolver->setDefault('api_data', function (Options $options) {

View File

@@ -31,7 +31,6 @@ class VisibilityType extends AbstractType
'yes' => VisibilityQuery::SHOW_VISIBLE,
'no' => VisibilityQuery::SHOW_HIDDEN,
],
//'attr' => ['class' => 'selectpicker', 'data-live-search' => false, 'data-width' => '100%']
]);
}

View File

@@ -25,7 +25,7 @@ class YesNoType extends AbstractType
{
$resolver->setDefaults([
'value' => true,
'false_values' => [null, 0, false],
'false_values' => [null, 0, false, 'false'],
'required' => false,
]);
}

View File

@@ -54,12 +54,22 @@ abstract class AbstractRenderer
}
/**
* @param $amount
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedMoney($amount)
protected function getFormattedTime(\DateTime $date)
{
return $this->extension->money($amount);
return $this->dateExtension->time($date);
}
/**
* @param $amount
* @param $currency
* @return string
*/
protected function getFormattedMoney($amount, $currency)
{
return $this->extension->money($amount, $currency);
}
/**

View File

@@ -38,7 +38,7 @@ class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-csv');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);

View File

@@ -38,7 +38,7 @@ class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-ods');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);

View File

@@ -49,11 +49,17 @@ trait RendererTrait
*/
abstract protected function getFormattedDateTime(\DateTime $date);
/**
* @param \DateTime $date
* @return mixed
*/
abstract protected function getFormattedTime(\DateTime $date);
/**
* @param $amount
* @return mixed
*/
abstract protected function getFormattedMoney($amount);
abstract protected function getFormattedMoney($amount, $currency);
/**
* @param \DateTime $date
@@ -75,6 +81,7 @@ trait RendererTrait
{
$customer = $model->getCustomer();
$project = $model->getQuery()->getProject();
$currency = $model->getCalculator()->getCurrency();
$values = [
'invoice.due_date' => $this->getFormattedDateTime($model->getDueDate()),
@@ -82,10 +89,10 @@ trait RendererTrait
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
'invoice.currency' => $model->getCalculator()->getCurrency(),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $this->getFormattedMoney($model->getCalculator()->getTax()),
'invoice.tax' => $this->getFormattedMoney($model->getCalculator()->getTax(), $currency),
'invoice.total_time' => $this->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.total' => $this->getFormattedMoney($model->getCalculator()->getTotal()),
'invoice.subtotal' => $this->getFormattedMoney($model->getCalculator()->getSubtotal()),
'invoice.total' => $this->getFormattedMoney($model->getCalculator()->getTotal(), $currency),
'invoice.subtotal' => $this->getFormattedMoney($model->getCalculator()->getSubtotal(), $currency),
'template.name' => $model->getTemplate()->getName(),
'template.company' => $model->getTemplate()->getCompany(),
@@ -156,6 +163,7 @@ trait RendererTrait
$activity = $timesheet->getActivity();
$project = $timesheet->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$begin = $timesheet->getBegin();
$end = $timesheet->getEnd();
@@ -164,15 +172,16 @@ trait RendererTrait
'entry.row' => '',
'entry.description' => $description,
'entry.amount' => $amount,
'entry.rate' => $this->getFormattedMoney($hourlyRate),
'entry.total' => $this->getFormattedMoney($rate),
'entry.rate' => $this->getFormattedMoney($hourlyRate, $currency),
'entry.total' => $this->getFormattedMoney($rate, $currency),
'entry.currency' => $currency,
'entry.duration' => $timesheet->getDuration(),
'entry.duration_minutes' => number_format($timesheet->getDuration() / 60),
'entry.begin' => $this->getFormattedDateTime($begin),
'entry.begin_time' => date('H:i', $begin->getTimestamp()),
'entry.begin_time' => $this->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $this->getFormattedDateTime($end),
'entry.end_time' => date('H:i', $end->getTimestamp()),
'entry.end_time' => $this->getFormattedTime($end),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $this->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(),

View File

@@ -38,7 +38,7 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterf
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-xslx');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-xlsx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);

View File

@@ -11,11 +11,13 @@ namespace App;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -30,6 +32,7 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
@@ -47,7 +50,8 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator');
$container->registerForAutoconfiguration(RendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
}
@@ -80,6 +84,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new ExportServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
protected function configureRoutes(RouteCollectionBuilder $routes)

View File

@@ -0,0 +1,65 @@
<?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 the exported column to the timesheet table
*/
final class Version20190124004014 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN exported BOOLEAN NOT NULL DEFAULT false');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD exported TINYINT(1) NOT NULL DEFAULT false');
}
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP exported');
}
}
}

View File

@@ -0,0 +1,71 @@
<?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 the timezone column to the timesheet table
* See https://github.com/kevinpapst/kimai2/pull/372 for further information.
*/
final class Version20190201150324 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
$timezone = date_default_timezone_get();
if ($platform === 'sqlite') {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN timezone VARCHAR(64) DEFAULT NULL');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD timezone VARCHAR(64) NOT NULL');
}
$this->addSql('UPDATE ' . $timesheet . ' SET timezone = "' . $timezone . '"');
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL --(DC2Type:datetime)
, end_time DATETIME DEFAULT NULL --(DC2Type:datetime)
, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL, exported BOOLEAN NOT NULL DEFAULT false)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate, exported FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP timezone');
}
}
}

View File

@@ -38,14 +38,15 @@ class ActivityRepository extends AbstractRepository
* @param User|null $user
* @param \DateTime|null $startFrom
* @return Timesheet[]
* @throws Query\QueryException
*/
public function getRecentActivities(User $user = null, \DateTime $startFrom = null)
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t', 'a', 'p', 'c')
->distinct()
$qb->select($qb->expr()->max('t.id'))
->from(Timesheet::class, 't')
->indexBy('t', 't.id')
->join('t.activity', 'a')
->join('t.project', 'p')
->join('p.customer', 'c')
@@ -53,8 +54,7 @@ class ActivityRepository extends AbstractRepository
->andWhere('a.visible = 1')
->andWhere('p.visible = 1')
->andWhere('c.visible = 1')
->groupBy('a.id', 't.id')
->orderBy('t.end', 'DESC')
->groupBy('a.id', 'p.id')
->setMaxResults(10)
;
@@ -68,6 +68,27 @@ class ActivityRepository extends AbstractRepository
->setParameter('begin', $startFrom);
}
$results = $qb->getQuery()->getScalarResult();
if (empty($results)) {
return [];
}
$ids = [];
foreach ($results as $result) {
$ids[] = $result[1];
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('t', 'a', 'p', 'c')
->from(Timesheet::class, 't')
->join('t.activity', 'a')
->join('t.project', 'p')
->join('p.customer', 'c')
->andWhere($qb->expr()->in('t.id', $ids))
->orderBy('t.end', 'DESC')
;
return $qb->getQuery()->getResult();
}
@@ -219,7 +240,6 @@ class ActivityRepository extends AbstractRepository
* @param Activity $delete
* @param Activity|null $replace
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteActivity(Activity $delete, ?Activity $replace = null)
{
@@ -229,15 +249,13 @@ class ActivityRepository extends AbstractRepository
try {
if (null !== $replace) {
$qb = $em->createQueryBuilder();
$query = $qb
->update(Timesheet::class, 't')
$qb->update(Timesheet::class, 't')
->set('t.activity', ':replace')
->where('t.activity = :delete')
->setParameter('delete', $delete)
->setParameter('replace', $replace)
->getQuery();
->setParameter('replace', $replace);
$result = $query->execute();
$qb->getQuery()->execute();
}
$em->remove($delete);

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\Repository\Query;
/**
* Can be used for export queries.
*/
class ExportQuery extends TimesheetQuery
{
public const TYPE_HTML = 'html';
public const TYPE_CSV = 'csv';
public const TYPE_PDF = 'pdf';
public const TYPE_XLSX = 'xlsx';
public const TYPE_ODS = 'ods';
/**
* @var string
*/
protected $type;
/**
* @return string
*/
public function getType(): ?string
{
return $this->type;
}
/**
* @param string $type
* @return ExportQuery
*/
public function setType(string $type)
{
if (in_array($type, [self::TYPE_PDF, self::TYPE_CSV, self::TYPE_HTML, self::TYPE_XLSX, self::TYPE_ODS])) {
$this->type = $type;
}
return $this;
}
}

View File

@@ -22,6 +22,8 @@ class InvoiceQuery extends TimesheetQuery
protected $template;
/**
* TODO can this be removed ???
*
* @var InvoiceTemplate[]
*/
protected $templates = [];

View File

@@ -11,6 +11,7 @@ namespace App\Repository\Query;
use App\Entity\Activity;
use App\Entity\User;
use App\Form\Model\DateRange;
/**
* Can be used for advanced timesheet repository queries.
@@ -20,6 +21,8 @@ class TimesheetQuery extends ActivityQuery
public const STATE_ALL = 1;
public const STATE_RUNNING = 2;
public const STATE_STOPPED = 3;
public const STATE_EXPORTED = 4;
public const STATE_NOT_EXPORTED = 5;
/**
* Overwritten for different default order
@@ -44,13 +47,18 @@ class TimesheetQuery extends ActivityQuery
*/
protected $state = self::STATE_ALL;
/**
* @var \DateTime
* @var int
*/
protected $begin;
protected $exported = self::STATE_ALL;
/**
* @var \DateTime
* @var DateRange
*/
protected $end;
protected $dateRange;
public function __construct()
{
$this->dateRange = new DateRange();
}
/**
* @return User
@@ -61,10 +69,10 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @param User $user
* @param User|int $user
* @return TimesheetQuery
*/
public function setUser(User $user = null)
public function setUser($user = null)
{
$this->user = $user;
@@ -82,10 +90,10 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @param Activity $activity
* @param Activity|int $activity
* @return TimesheetQuery
*/
public function setActivity(Activity $activity = null)
public function setActivity($activity = null)
{
$this->activity = $activity;
@@ -106,7 +114,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function setState($state)
{
if (!is_int($state) && $state != (int) $state) {
if (!is_int($state) && $state !== (int) $state) {
return $this;
}
@@ -118,12 +126,38 @@ class TimesheetQuery extends ActivityQuery
return $this;
}
/**
* @return int
*/
public function getExported()
{
return $this->exported;
}
/**
* @param int $exported
* @return TimesheetQuery
*/
public function setExported($exported)
{
if (!is_int($exported) && $exported !== (int) $exported) {
return $this;
}
$exported = (int) $exported;
if (in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
$this->exported = $exported;
}
return $this;
}
/**
* @return \DateTime
*/
public function getBegin()
{
return $this->begin;
return $this->dateRange->getBegin();
}
/**
@@ -132,7 +166,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function setBegin($begin)
{
$this->begin = $begin;
$this->dateRange->setBegin($begin);
return $this;
}
@@ -142,7 +176,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function getEnd()
{
return $this->end;
return $this->dateRange->getEnd();
}
/**
@@ -151,7 +185,26 @@ class TimesheetQuery extends ActivityQuery
*/
public function setEnd($end)
{
$this->end = $end;
$this->dateRange->setEnd($end);
return $this;
}
/**
* @return DateRange
*/
public function getDateRange(): DateRange
{
return $this->dateRange;
}
/**
* @param DateRange $dateRange
* @return TimesheetQuery
*/
public function setDateRange(DateRange $dateRange)
{
$this->dateRange = $dateRange;
return $this;
}

View File

@@ -9,6 +9,7 @@
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Model\Statistic\Month;
@@ -29,6 +30,18 @@ class TimesheetRepository extends AbstractRepository
public const STATS_QUERY_ACTIVE = 'active';
public const STATS_QUERY_MONTHLY = 'monthly';
/**
* @param Timesheet $timesheet
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function save(Timesheet $timesheet)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($timesheet);
$entityManager->flush();
}
/**
* @param Timesheet $entry
* @return bool
@@ -43,8 +56,11 @@ class TimesheetRepository extends AbstractRepository
}
// seems to be necessary so Doctrine will recognize a changed timestamp
$entry->setBegin(clone $entry->getBegin());
$entry->setEnd(new DateTime());
$begin = clone $entry->getBegin();
$end = new \DateTime('now', $begin->getTimezone());
$entry->setBegin($begin);
$entry->setEnd($end);
$entityManager = $this->getEntityManager();
$entityManager->persist($entry);
@@ -91,11 +107,11 @@ class TimesheetRepository extends AbstractRepository
/**
* @param $select
* @param User|null $user
* @param User $user
* @return int
* @throws \Doctrine\ORM\NonUniqueResultException
*/
protected function queryThisMonth($select, ?User $user)
protected function queryThisMonth($select, User $user)
{
$begin = new DateTime('first day of this month 00:00:00');
$end = new DateTime('last day of this month 23:59:59');
@@ -338,12 +354,18 @@ class TimesheetRepository extends AbstractRepository
->setParameter('end', $query->getEnd());
}
if ($query->getExported() === TimesheetQuery::STATE_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true);
} elseif ($query->getExported() === TimesheetQuery::STATE_NOT_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false);
}
if (null !== $query->getActivity()) {
$qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity());
}
if (null === $query->getActivity() || null === $query->getActivity()->getProject()) {
if (null === $query->getActivity() || ($query->getActivity() instanceof Activity && null === $query->getActivity()->getProject())) {
if (null !== $query->getProject()) {
$qb->andWhere('t.project = :project')
->setParameter('project', $query->getProject());

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