Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b57bd51ca | ||
|
|
49c78ddd9d | ||
|
|
47b7046743 | ||
|
|
b4f1cae323 | ||
|
|
17856e0b96 | ||
|
|
8005679e60 | ||
|
|
ef33233624 | ||
|
|
2c6f57c7ce | ||
|
|
fd60d5bb17 | ||
|
|
742e6eb3a3 | ||
|
|
331ea586f1 | ||
|
|
afb0eabaa4 | ||
|
|
afa0aa5b3a | ||
|
|
1f99f69ecd | ||
|
|
d00596d297 | ||
|
|
8dcc18dde3 | ||
|
|
616d514d5a | ||
|
|
b2587c71b8 | ||
|
|
816866549c | ||
|
|
062735c9e3 | ||
|
|
aae9183de6 | ||
|
|
7f3e2d453f | ||
|
|
1b42aed34c | ||
|
|
974806d145 | ||
|
|
fe4c3c10d6 |
22
.github/ISSUE_TEMPLATE.md
vendored
22
.github/ISSUE_TEMPLATE.md
vendored
@@ -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):
|
||||
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
2
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
about: Suggest an idea to make Kimai better
|
||||
|
||||
---
|
||||
|
||||
|
||||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -1,6 +1,10 @@
|
||||
build:
|
||||
environment:
|
||||
node: v8.11.2
|
||||
php:
|
||||
version: 7.2
|
||||
ini:
|
||||
date.timezone: "UTC"
|
||||
nodes:
|
||||
analysis:
|
||||
tests:
|
||||
|
||||
@@ -20,6 +20,7 @@ matrix:
|
||||
before_install:
|
||||
- phpenv config-rm xdebug.ini
|
||||
- composer self-update
|
||||
- php -i
|
||||
|
||||
install:
|
||||
- composer install
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -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))
|
||||
|
||||
10
README.md
10
README.md
@@ -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
|
||||
|
||||
53
UPGRADING.md
53
UPGRADING.md
@@ -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:
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -38,3 +38,4 @@ footer.main-footer {
|
||||
@import 'dashboard';
|
||||
@import 'navbar';
|
||||
@import 'documentation';
|
||||
@import 'daterangepicker';
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
assets/sass/daterangepicker.scss
Normal file
12
assets/sass/daterangepicker.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
.drp-calendar {
|
||||
.calendar-table {
|
||||
td.today:not(.active) {
|
||||
background-color: lighten(#357ebd, 40);
|
||||
color: #357ebd;
|
||||
}
|
||||
select.yearselect{
|
||||
width: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
*/
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
1464
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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')")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')")
|
||||
|
||||
43
src/Command/BashExecutor.php
Normal file
43
src/Command/BashExecutor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
70
src/Command/BashResult.php
Normal file
70
src/Command/BashResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
149
src/Command/ConvertTimezoneCommand.php
Normal file
149
src/Command/ConvertTimezoneCommand.php
Normal 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('');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 : '')
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
162
src/Controller/ExportController.php
Normal file
162
src/Controller/ExportController.php
Normal 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'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' => []
|
||||
];
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
;
|
||||
|
||||
74
src/Doctrine/UTCDateTimeType.php
Normal file
74
src/Doctrine/UTCDateTimeType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
77
src/EventSubscriber/TimezoneSubscriber.php
Normal file
77
src/EventSubscriber/TimezoneSubscriber.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
215
src/Export/Renderer/AbstractSpreadsheetRenderer.php
Normal file
215
src/Export/Renderer/AbstractSpreadsheetRenderer.php
Normal 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;
|
||||
}
|
||||
71
src/Export/Renderer/CsvRenderer.php
Normal file
71
src/Export/Renderer/CsvRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
79
src/Export/Renderer/HtmlRenderer.php
Normal file
79
src/Export/Renderer/HtmlRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
71
src/Export/Renderer/OdsRenderer.php
Normal file
71
src/Export/Renderer/OdsRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
102
src/Export/Renderer/PDFRenderer.php
Normal file
102
src/Export/Renderer/PDFRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
43
src/Export/Renderer/RendererTrait.php
Normal file
43
src/Export/Renderer/RendererTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
src/Export/Renderer/XlsxRenderer.php
Normal file
71
src/Export/Renderer/XlsxRenderer.php
Normal 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';
|
||||
}
|
||||
}
|
||||
39
src/Export/RendererInterface.php
Normal file
39
src/Export/RendererInterface.php
Normal 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;
|
||||
}
|
||||
54
src/Export/ServiceExport.php
Normal file
54
src/Export/ServiceExport.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
src/Form/Model/DateRange.php
Normal file
62
src/Form/Model/DateRange.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
73
src/Form/Toolbar/ExportToolbarForm.php
Normal file
73
src/Form/Toolbar/ExportToolbarForm.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
78
src/Form/Type/DatePickerType.php
Normal file
78
src/Form/Type/DatePickerType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
194
src/Form/Type/DateRangeType.php
Normal file
194
src/Form/Type/DateRangeType.php
Normal 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';
|
||||
}
|
||||
}
|
||||
80
src/Form/Type/DateTimePickerType.php
Normal file
80
src/Form/Type/DateTimePickerType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
89
src/Form/Type/InitialViewType.php
Normal file
89
src/Form/Type/InitialViewType.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%']
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
65
src/Migrations/Version20190124004014.php
Normal file
65
src/Migrations/Version20190124004014.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/Migrations/Version20190201150324.php
Normal file
71
src/Migrations/Version20190201150324.php
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
48
src/Repository/Query/ExportQuery.php
Normal file
48
src/Repository/Query/ExportQuery.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\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;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ class InvoiceQuery extends TimesheetQuery
|
||||
protected $template;
|
||||
|
||||
/**
|
||||
* TODO can this be removed ???
|
||||
*
|
||||
* @var InvoiceTemplate[]
|
||||
*/
|
||||
protected $templates = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user