Compare commits

...

53 Commits
0.4 ... 0.6

Author SHA1 Message Date
Kevin Papst
417675b3df prepare release 0.6 (#412) 2018-11-18 20:45:00 +01:00
Lukas
321ac74b6a added parsedown extension for header ids (#388) (#399) 2018-11-18 20:31:37 +01:00
Kevin Papst
b1c06eed7b API endpoint for Timesheet entries (#332) 2018-11-18 19:43:58 +01:00
Kevin Papst
8cb52e22b1 fix column visibility if unconfigured / no cookie existing (#423) 2018-11-18 02:13:44 +01:00
Kevin Papst
a073064900 redirect to user language after profile update (#421) 2018-11-15 12:06:57 +01:00
Kevin Papst
07cceca67e fix duration and rate for html invoices (#414) 2018-11-12 23:32:05 +01:00
Kevin Papst
540318fcd0 toolbar - always allow to choose global activities (#410) 2018-11-12 15:00:25 +01:00
Kevin Papst
e9cbb872d0 added further timesheet related invoice fields (#411) 2018-11-12 12:19:57 +01:00
Kevin Papst
f9dc42d1f9 option to move entries from one entity to another upon deletion (#409) 2018-11-12 00:01:49 +01:00
Kevin Papst
1dabeedaa0 show toolbar filter after submission (#407) 2018-11-11 15:32:41 +01:00
Kevin Papst
dc70ff128c full configurable data columns in all screen sizes (#404) 2018-11-10 22:22:08 +01:00
Kevin Papst
b012b7170b added global activity support in importer (#402) 2018-11-10 03:05:33 +01:00
Toby Batch
0a1c2d8440 updated docker documentation #397 (#398) 2018-11-09 14:07:49 +01:00
Toby Batch
d72596f000 added links to docker hub and production docker repo (#396) 2018-11-08 23:07:33 +01:00
Lukas
33029b2a11 fixed docu: issues- & milestone links (#395) 2018-11-07 00:45:58 +01:00
Kevin Papst
bae24dd0ec beta test: added quick search box for customers, projects and activities (#392) 2018-11-06 23:35:09 +01:00
Kevin Papst
db7de3aac1 added global activities (#259) 2018-11-06 23:10:34 +01:00
Kevin Papst
c66dfbc653 added links to further docker images by the community (#394) 2018-11-06 22:30:44 +01:00
Kevin Papst
51c207e025 fixed broken toolbar form with empty date (#391) 2018-11-05 22:26:57 +01:00
Lukas
e44e2442af updated invoice documentation (#387) 2018-11-04 15:55:34 +01:00
infeeeee
86f40083ef Added Hungarian translation (#385) 2018-11-02 23:38:12 +01:00
Kevin Papst
f45fbbce60 improved docs and cross-linked docker (#383) 2018-11-02 16:11:40 +01:00
srdco
c648866cb4 Update installation instructions for non-server (#382) 2018-11-01 12:28:39 +01:00
Kevin Papst
f8adafdabe invoice calculator to group entries by activity (#380) 2018-10-31 15:10:06 +01:00
Toby Batch
ab4a1a0a1d added dockerfile for development (#377) 2018-10-31 13:19:48 +01:00
Kevin Papst
34a3f4e275 about screen with debug information for super-admin (#378) 2018-10-29 15:28:52 +01:00
Kevin Papst
55aedcd4a8 bump version number for development (#375) 2018-10-25 22:27:43 +02:00
Kevin Papst
6633c550f5 user-profile - sidebar links and language settings (#369) 2018-10-23 23:59:38 +02:00
Kevin Papst
0165658659 added config for initial calendar view (#363) 2018-10-21 15:43:16 +02:00
Toby Batch
3075276a52 added default settings for new customer form (#358) 2018-10-21 15:27:43 +02:00
Kevin Papst
b46e752d0a decrease minimum username length to 3 character (#362) 2018-10-19 16:50:03 +02:00
Kevin Papst
ad171e7706 removed deprecated symfony/lts and updated all other packages (#354) 2018-10-12 17:51:38 +02:00
Kevin Papst
92c665436a updated admin lte theme bundle (#350) 2018-10-08 20:58:21 +02:00
Kevin Papst
2dbf433474 fixed redirect route after password reset (#345) 2018-10-07 19:12:59 +02:00
Kevin Papst
56ac642446 arabic translations and locale support in datepicker (#337) 2018-10-07 18:29:12 +02:00
Kevin Papst
2f6c0d2208 updated timesheet and email documentation (#349) 2018-10-07 16:50:20 +02:00
Kevin Papst
a32614fff8 updated required dependencies (#346) 2018-10-07 16:43:54 +02:00
Kevin Papst
53053c127e prepare release 0.5 (#326) 2018-09-27 13:35:03 +02:00
Kevin Papst
a89ef41e81 added copy invoice template action (#331) 2018-09-26 17:50:46 +02:00
Kevin Papst
eee7cb9bf4 fix spreadsheet renderer for invoices with one entry (#328) 2018-09-25 21:16:41 +02:00
Kevin Papst
98dc38ed99 timesheet export (#317)
* remove autocomplete for begin and end
* fix minute display in timesheet-edit form
* toolbar form label in separate row
* added export action
2018-09-24 16:46:10 +02:00
Kevin Papst
7bcd6a6382 added version command and api endpoint (#321) 2018-09-24 15:56:29 +02:00
Kevin Papst
73e0a75585 invoice VAT as float and database key length (#323) 2018-09-24 15:03:51 +02:00
Kevin Papst
71aa899f77 added invoice voter (#316) 2018-09-22 22:38:51 +02:00
Kevin Papst
53f82a808b improved invoices with new renderer (#306)
* added docx renderer and demo template
* added csv renderer and demo template
* added xlsx renderer and demo template
* added ods renderer and demo template
* added user calculator
* added invoice documentation
2018-09-21 01:27:56 +02:00
Kevin Papst
b035fc9ebd add stable controller tests (#305) 2018-09-07 00:44:57 +02:00
Kevin Papst
8cae5e8b3f added fixed-rate and hourly-rate for customer, project and activity (#304) 2018-09-06 23:37:25 +02:00
Kevin Papst
6163f33abf add hourly_rate and fixed_rate to timesheet entries (#302) 2018-09-05 15:27:39 +02:00
Kevin Papst
a636faa4ab added freelancer invoice template (#254) 2018-09-02 21:29:49 +02:00
Kevin Papst
ee4df36735 allow to switch language on any page (#299) 2018-09-02 20:58:19 +02:00
Thierry Pasquier
4ede5463f9 Fix duration_short format for french (#297) 2018-09-02 17:03:38 +02:00
Kevin Papst
4c277fe1b5 allow markdown in timesheet descriptions (#296) 2018-09-02 12:10:49 +02:00
Kevin Papst
9f6079ad8a added option to delete invoice template (#294) 2018-09-02 11:36:04 +02:00
348 changed files with 15211 additions and 2151 deletions

View File

@@ -1,2 +1,3 @@
unreleased=true
future-release=0.4
future-release=0.6
exclude-labels=duplicate,question,invalid,wontfix,release

7
.gitignore vendored
View File

@@ -7,11 +7,16 @@
!bin/console
/config/packages/local.yaml
/var/data/kimai.sqlite
templates/invoice/renderer/.~lock*
/var/data/*.sqlite
!/var/data/kimai_test.sqlite
/var/coverage/
/var/cache/*
!var/cache/.gitkeep
/var/invoices/*
/var/log/*
!var/log/.gitkeep

View File

@@ -12,11 +12,13 @@ build:
override:
-
command: 'vendor/bin/phpunit --coverage-clover=clover.xml tests/'
stop_on_failure: true
coverage:
file: 'clover.xml'
format: 'clover'
-
command: 'bin/console kimai:phpcs --checkstyle=checkstyle'
stop_on_failure: true
analysis:
file: 'checkstyle'
format: 'php-cs-checkstyle'
@@ -33,3 +35,8 @@ filter:
dependency_paths:
- 'node_modules/'
- 'vendor/'
build_failure_conditions:
- 'project.metric("scrutinizer.quality", < 9.30)'
- 'project.metric("scrutinizer.test_coverage", < 0.73)'
- 'project.metric_change("scrutinizer.test_coverage", < -0.01)'

View File

@@ -18,7 +18,7 @@ matrix:
- php: 7.2
before_install:
- if [[ "$TRAVIS_PHP_VERSION" != "hhvm" ]]; then phpenv config-rm xdebug.ini; fi
- phpenv config-rm xdebug.ini
- composer self-update
install:
@@ -29,6 +29,8 @@ script:
- cp tests/.env.dist.sqlite .env
- bin/console doctrine:database:create -n
- bin/console doctrine:migrations:migrate -n
- bin/console doctrine:migrations:migrate first -n
- cp tests/.env.dist.mysql .env
- bin/console doctrine:database:create -n
- bin/console doctrine:migrations:migrate -n
- bin/console doctrine:migrations:migrate first -n

View File

@@ -1,5 +1,120 @@
# Change Log
## [0.6](https://github.com/kevinpapst/kimai2/tree/0.6) (2018-11-18)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.5...0.6)
**Fixed bugs:**
- Invoicing export - 500: Internal Server Error [\#390](https://github.com/kevinpapst/kimai2/issues/390)
- invalid CSRF-Token [\#344](https://github.com/kevinpapst/kimai2/issues/344)
- fix column visibility if unconfigured \(no cookie existing\) [\#423](https://github.com/kevinpapst/kimai2/pull/423) ([kevinpapst](https://github.com/kevinpapst))
- fix duration and rate for html invoices [\#414](https://github.com/kevinpapst/kimai2/pull/414) ([kevinpapst](https://github.com/kevinpapst))
- fixed broken toolbar form with empty date [\#391](https://github.com/kevinpapst/kimai2/pull/391) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Language setting does not work immediately [\#418](https://github.com/kevinpapst/kimai2/issues/418)
- After installation no graphic/background/icons shown [\#417](https://github.com/kevinpapst/kimai2/issues/417)
- Form fields should be always visible [\#408](https://github.com/kevinpapst/kimai2/issues/408)
- Merge projects and/or activities [\#406](https://github.com/kevinpapst/kimai2/issues/406)
- Active filter should stay open [\#405](https://github.com/kevinpapst/kimai2/issues/405)
- Make project field optional [\#401](https://github.com/kevinpapst/kimai2/issues/401)
- Global activities support for Kimai 1 importer [\#400](https://github.com/kevinpapst/kimai2/issues/400)
- Unrecognized Options "renderer, number\_generator" under "kimai.invoice" [\#381](https://github.com/kevinpapst/kimai2/issues/381)
- Invoice Grouped by Activity [\#379](https://github.com/kevinpapst/kimai2/issues/379)
- Set the order of columns \(in my times page\) [\#371](https://github.com/kevinpapst/kimai2/issues/371)
- Columns on small/narrow displays \(mobile\) on „my times“ page [\#370](https://github.com/kevinpapst/kimai2/issues/370)
- Make settings more accessible [\#365](https://github.com/kevinpapst/kimai2/issues/365)
- Data validation for register page username field. [\#360](https://github.com/kevinpapst/kimai2/issues/360)
- Option for setting default calendar view [\#359](https://github.com/kevinpapst/kimai2/issues/359)
- 500: Internal Server Error after login \(new install\) [\#342](https://github.com/kevinpapst/kimai2/issues/342)
- Invoices export error [\#341](https://github.com/kevinpapst/kimai2/issues/341)
- Activities just Duration [\#334](https://github.com/kevinpapst/kimai2/issues/334)
- API: Add filtering/sorting/limits [\#333](https://github.com/kevinpapst/kimai2/issues/333)
- add the same activity for multiple projects [\#325](https://github.com/kevinpapst/kimai2/issues/325)
- API: Endpoint for API docs needs session cookie [\#319](https://github.com/kevinpapst/kimai2/issues/319)
- API: Timesheets [\#315](https://github.com/kevinpapst/kimai2/issues/315)
- Feature request: Quick search box for activities when creating new timesheet [\#307](https://github.com/kevinpapst/kimai2/issues/307)
- docker image for kimai2 [\#284](https://github.com/kevinpapst/kimai2/issues/284)
- Separate Customer / Project / Activity input when logging time [\#250](https://github.com/kevinpapst/kimai2/issues/250)
- Interactive help after fresh installation [\#94](https://github.com/kevinpapst/kimai2/issues/94)
**Merged pull requests:**
- redirect to user language after profile update [\#421](https://github.com/kevinpapst/kimai2/pull/421) ([kevinpapst](https://github.com/kevinpapst))
- added more timesheet related invoice fields [\#411](https://github.com/kevinpapst/kimai2/pull/411) ([kevinpapst](https://github.com/kevinpapst))
- toolbar improvements [\#410](https://github.com/kevinpapst/kimai2/pull/410) ([kevinpapst](https://github.com/kevinpapst))
- move entries from one entity to another upon deletion [\#409](https://github.com/kevinpapst/kimai2/pull/409) ([kevinpapst](https://github.com/kevinpapst))
- show toolbar filter if they were submitted [\#407](https://github.com/kevinpapst/kimai2/pull/407) ([kevinpapst](https://github.com/kevinpapst))
- full configurable data columns in all screen sizes [\#404](https://github.com/kevinpapst/kimai2/pull/404) ([kevinpapst](https://github.com/kevinpapst))
- added global activity support in importer [\#402](https://github.com/kevinpapst/kimai2/pull/402) ([kevinpapst](https://github.com/kevinpapst))
- added parsedown extension for header ids \(\#388\) [\#399](https://github.com/kevinpapst/kimai2/pull/399) ([lduer](https://github.com/lduer))
- Fix for \#397 [\#398](https://github.com/kevinpapst/kimai2/pull/398) ([tobybatch](https://github.com/tobybatch))
- Added links to docker hub and my docker repo [\#396](https://github.com/kevinpapst/kimai2/pull/396) ([tobybatch](https://github.com/tobybatch))
- fixed issues- & milestone links [\#395](https://github.com/kevinpapst/kimai2/pull/395) ([lduer](https://github.com/lduer))
- added links to further docker images by the community [\#394](https://github.com/kevinpapst/kimai2/pull/394) ([kevinpapst](https://github.com/kevinpapst))
- beta test: added quick search box for customers, projects and activities [\#392](https://github.com/kevinpapst/kimai2/pull/392) ([kevinpapst](https://github.com/kevinpapst))
- updated the documentation, reflecting the changes in \#306 [\#387](https://github.com/kevinpapst/kimai2/pull/387) ([lduer](https://github.com/lduer))
- Added Hungarian translation and fixed the path of kimai.yaml in translation documentation [\#385](https://github.com/kevinpapst/kimai2/pull/385) ([infeeeee](https://github.com/infeeeee))
- improved docs and cross-linked docker [\#383](https://github.com/kevinpapst/kimai2/pull/383) ([kevinpapst](https://github.com/kevinpapst))
- Doc update of install/update instructions re: username [\#382](https://github.com/kevinpapst/kimai2/pull/382) ([srdco](https://github.com/srdco))
- invoice calculator to group entries by activity [\#380](https://github.com/kevinpapst/kimai2/pull/380) ([kevinpapst](https://github.com/kevinpapst))
- basic about screen with server and debug infos [\#378](https://github.com/kevinpapst/kimai2/pull/378) ([kevinpapst](https://github.com/kevinpapst))
- Dev/demo dockerfile [\#377](https://github.com/kevinpapst/kimai2/pull/377) ([tobybatch](https://github.com/tobybatch))
- Sidebar - user-profile links and language settings [\#369](https://github.com/kevinpapst/kimai2/pull/369) ([kevinpapst](https://github.com/kevinpapst))
- added configurable initial calendar view [\#363](https://github.com/kevinpapst/kimai2/pull/363) ([kevinpapst](https://github.com/kevinpapst))
- decrease minimum username to 3 character [\#362](https://github.com/kevinpapst/kimai2/pull/362) ([kevinpapst](https://github.com/kevinpapst))
- Default country \(\#2\) [\#358](https://github.com/kevinpapst/kimai2/pull/358) ([tobybatch](https://github.com/tobybatch))
- composer: removed deprecated symfony/lt, updated others [\#354](https://github.com/kevinpapst/kimai2/pull/354) ([kevinpapst](https://github.com/kevinpapst))
- updated admin-lte theme bundle to 2.1.1 [\#350](https://github.com/kevinpapst/kimai2/pull/350) ([kevinpapst](https://github.com/kevinpapst))
- updated timesheet and email documentation [\#349](https://github.com/kevinpapst/kimai2/pull/349) ([kevinpapst](https://github.com/kevinpapst))
- updated required dependencies [\#346](https://github.com/kevinpapst/kimai2/pull/346) ([kevinpapst](https://github.com/kevinpapst))
- Fix password reset [\#345](https://github.com/kevinpapst/kimai2/pull/345) ([kevinpapst](https://github.com/kevinpapst))
- added arabic translations and locale support in datepicker [\#337](https://github.com/kevinpapst/kimai2/pull/337) ([kevinpapst](https://github.com/kevinpapst))
- API endpoint for Timesheet entries [\#332](https://github.com/kevinpapst/kimai2/pull/332) ([kevinpapst](https://github.com/kevinpapst))
- Support global activities [\#259](https://github.com/kevinpapst/kimai2/pull/259) ([kevinpapst](https://github.com/kevinpapst))
## [0.5](https://github.com/kevinpapst/kimai2/tree/0.5) (2018-09-27)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.4...0.5)
**Fixed bugs:**
- Excel Output not containing data [\#327](https://github.com/kevinpapst/kimai2/issues/327)
**Closed issues:**
- Creating database schema throws exceptions [\#322](https://github.com/kevinpapst/kimai2/issues/322)
- API: Kimai metadata endpoint [\#320](https://github.com/kevinpapst/kimai2/issues/320)
- Support Markdown in timesheet description [\#295](https://github.com/kevinpapst/kimai2/issues/295)
- Add permission voter for invoices and templates [\#293](https://github.com/kevinpapst/kimai2/issues/293)
- Add support for MS Office templates [\#283](https://github.com/kevinpapst/kimai2/issues/283)
- Adding rates to customers, projects and tasks [\#271](https://github.com/kevinpapst/kimai2/issues/271)
- Delete invoice templates [\#267](https://github.com/kevinpapst/kimai2/issues/267)
- Sum of hours in timesheet invoice [\#262](https://github.com/kevinpapst/kimai2/issues/262)
- Feature request - API [\#251](https://github.com/kevinpapst/kimai2/issues/251)
- Feature request - PDF Download for monthly report [\#244](https://github.com/kevinpapst/kimai2/issues/244)
- Add support for OpenOffice templates [\#223](https://github.com/kevinpapst/kimai2/issues/223)
- Feature Request - Excel Output of times [\#221](https://github.com/kevinpapst/kimai2/issues/221)
- Add hourly rate options [\#111](https://github.com/kevinpapst/kimai2/issues/111)
**Merged pull requests:**
- added copy invoice template action [\#331](https://github.com/kevinpapst/kimai2/pull/331) ([kevinpapst](https://github.com/kevinpapst))
- fix spreadsheet renderer for invoices with one entry [\#328](https://github.com/kevinpapst/kimai2/pull/328) ([kevinpapst](https://github.com/kevinpapst))
- invoice VAT as float and database key length [\#323](https://github.com/kevinpapst/kimai2/pull/323) ([kevinpapst](https://github.com/kevinpapst))
- added version command + api endpoint [\#321](https://github.com/kevinpapst/kimai2/pull/321) ([kevinpapst](https://github.com/kevinpapst))
- Timesheet export [\#317](https://github.com/kevinpapst/kimai2/pull/317) ([kevinpapst](https://github.com/kevinpapst))
- added invoice voter [\#316](https://github.com/kevinpapst/kimai2/pull/316) ([kevinpapst](https://github.com/kevinpapst))
- Refactored invoice system [\#306](https://github.com/kevinpapst/kimai2/pull/306) ([kevinpapst](https://github.com/kevinpapst))
- add stable controller tests [\#305](https://github.com/kevinpapst/kimai2/pull/305) ([kevinpapst](https://github.com/kevinpapst))
- Fixed rate and Hourly rates [\#304](https://github.com/kevinpapst/kimai2/pull/304) ([kevinpapst](https://github.com/kevinpapst))
- add hourly\_rate and fixed\_rate to timesheet entries [\#302](https://github.com/kevinpapst/kimai2/pull/302) ([kevinpapst](https://github.com/kevinpapst))
- allow to switch language on any page [\#299](https://github.com/kevinpapst/kimai2/pull/299) ([kevinpapst](https://github.com/kevinpapst))
- Fix duration\_short format for french translation [\#297](https://github.com/kevinpapst/kimai2/pull/297) ([jeau](https://github.com/jeau))
- allow markdown in timesheet descriptions [\#296](https://github.com/kevinpapst/kimai2/pull/296) ([kevinpapst](https://github.com/kevinpapst))
- added option to delete invoice template [\#294](https://github.com/kevinpapst/kimai2/pull/294) ([kevinpapst](https://github.com/kevinpapst))
- Improved invoice preview [\#254](https://github.com/kevinpapst/kimai2/pull/254) ([kevinpapst](https://github.com/kevinpapst))
## [0.4](https://github.com/kevinpapst/kimai2/tree/0.4) (2018-09-01)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.3...0.4)
@@ -12,11 +127,10 @@
**Closed issues:**
- Improve dashboard display [\#280](https://github.com/kevinpapst/kimai2/issues/280)
- CSV export [\#263](https://github.com/kevinpapst/kimai2/issues/263)
- Spanish Translation Contrib [\#248](https://github.com/kevinpapst/kimai2/issues/248)
- Administration of projects throws error if locale is "de" [\#247](https://github.com/kevinpapst/kimai2/issues/247)
- Web Installer Package for near-automated installs from browser [\#235](https://github.com/kevinpapst/kimai2/issues/235)
- Improve dashboard display [\#280](https://github.com/kevinpapst/kimai2/issues/280)
- Delete user [\#225](https://github.com/kevinpapst/kimai2/issues/225)
- Feature request - Monthly overview / report [\#207](https://github.com/kevinpapst/kimai2/issues/207)
@@ -74,7 +188,6 @@
**Merged pull requests:**
- prepare release 0.3 [\#231](https://github.com/kevinpapst/kimai2/pull/231) ([kevinpapst](https://github.com/kevinpapst))
- Easier creation of Tasks/Projects/User [\#230](https://github.com/kevinpapst/kimai2/pull/230) ([kevinpapst](https://github.com/kevinpapst))
- Added email configuration docs [\#228](https://github.com/kevinpapst/kimai2/pull/228) ([kevinpapst](https://github.com/kevinpapst))
- Show revenue only for ROLE\_ADMIN [\#227](https://github.com/kevinpapst/kimai2/pull/227) ([kevinpapst](https://github.com/kevinpapst))
@@ -148,7 +261,6 @@
**Merged pull requests:**
- \[WIP\] Translations [\#173](https://github.com/kevinpapst/kimai2/pull/173) ([simonschaufi](https://github.com/simonschaufi))
- prepare v0.2 release \#177 [\#178](https://github.com/kevinpapst/kimai2/pull/178) ([kevinpapst](https://github.com/kevinpapst))
- various documentation updates [\#176](https://github.com/kevinpapst/kimai2/pull/176) ([kevinpapst](https://github.com/kevinpapst))
- code styles: header\_comment and ordered\_imports [\#175](https://github.com/kevinpapst/kimai2/pull/175) ([kevinpapst](https://github.com/kevinpapst))
- Code sniffer rules \#158 [\#172](https://github.com/kevinpapst/kimai2/pull/172) ([kevinpapst](https://github.com/kevinpapst))

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# 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.
FROM php:7.2.9-apache-stretch
WORKDIR /opt/kimai
RUN php -r "readfile('http://getcomposer.org/installer');" | php -- --install-dir=/usr/bin/ --filename=composer && \
apt update && \
apt install -y \
git \
libicu-dev \
libjpeg-dev \
libldap2-dev \
libldb-dev \
libpng-dev \
unzip \
zip \
&& \
docker-php-ext-install \
gd \
intl \
ldap \
pdo_mysql \
zip && \
apt-get -y autoremove && \
apt-get clean && \
git clone https://github.com/kevinpapst/kimai2.git /opt/kimai && \
sed "s/prod/dev/g" .env.dist > .env && \
composer install --dev --optimize-autoloader && \
bin/console doctrine:database:create && \
bin/console doctrine:schema:create && \
bin/console doctrine:migrations:version --add --all && \
bin/console cache:warmup && \
chown -R www-data:www-data var
EXPOSE 8001
USER www-data
CMD /opt/kimai/bin/console server:run 0.0.0.0:8001

10
LICENSE
View File

@@ -1,11 +1,13 @@
MIT License
Copyright (c) 2017-2018 Kevin Papst
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
@@ -15,5 +17,5 @@ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -15,21 +15,23 @@ This is the reloaded version of the open source timetracker Kimai.
The new version has not much in common with its predecessor [Kimai v1](http://www.kimai.org) besides the basic ideas of time-tracking and the current development team.
Right now its in an early development phase, its usable but some advanced features from Kimai v1 are missing by now (like export and ODT invoices).
But we already support to [import your timesheets](migration_v1.md) from Kimai v1.
But we already support to [import your timesheets](var/docs/migration_v1.md) from Kimai v1.
It is developed with modern frameworks like [Symfony v4](https://github.com/symfony/symfony), [Doctrine](https://github.com/doctrine/),
[AdminLTE](https://github.com/kevinpapst/AdminLTEBundle/) and [many](composer.json) [more](package.json).
Kimai is a multi-language application and already translated to English. German, Italian, Frensh, Spanish and Russian.
Kimai is a multi-language application and already translated to: English, German, Italian, French, Spanish, Russian, Arabic and Hungarian.
If you want to support us in translating Kimai, please [read this documentation](var/docs/translations.md).
### Requirements
- PHP 7.1.3 or higher
- One PHP extension of [PDO-SQLite](https://php.net/manual/en/ref.pdo-sqlite.php) or [PDO-MySQL](https://php.net/manual/en/ref.pdo-mysql.php) enabled (it might work with PostgreSQL and Oracle as well, but that wasn't tested and is not officially supported)
- The PHP extension [intl](https://php.net/manual/en/book.intl.php)
- The PHP extensions:
- [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)
- [intl](https://php.net/manual/en/book.intl.php)
- [zip](https://php.net/manual/en/book.zip.php)
- The [usual Symfony application requirements](http://symfony.com/doc/current/reference/requirements.html)
- If you use MariaDB, make sure its at least v10.7.2 (see [FAQ](var/docs/faq.md))
- 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
@@ -37,13 +39,16 @@ If you want to support us in translating Kimai, please [read this documentation]
Looking for more information about using Kimai? Check out our more detailed [documentation](var/docs/).
You can see our development roadmap for the future in the [Milestones](milestones/) sections.
You can see our development roadmap for the future in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
Our roadmap is open for changes and input from the community, please [sent us](issues/) your ideas and questions.
Our roadmap is open for changes and input from the community, please [send us](https://github.com/kevinpapst/kimai2/issues) your ideas and questions.
## Installation
There are [further infos about installation](var/docs/installation.md) if you have to use FTP or want to develop with Kimai.
> **NOTE**
>
> There are [further infos about installation](var/docs/installation.md) if you have to use FTP, want to develop with Kimai
or are setting up Kimai on your personal computer. There is also a [docker image](var/docs/docker.md) available.
If you want to install Kimai 2 in your production environment, then SSH into your server and change to your webserver root.
You need to install Git and [Composer](https://getcomposer.org/doc/00-intro.md) if you haven't already.
@@ -55,18 +60,20 @@ git clone https://github.com/kevinpapst/kimai2.git
cd kimai2/
```
Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html):
Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html) and create your `.env` file:
```bash
chown -R www-data var/
chmod -R 777 var/
chown -R :www-data .
chmod -R g+r .
chmod -R g+rw var/
cp .env.dist .env
```
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite.
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite, but SQLite is [not recommended](var/docs/faq.md) for production usage.
Configure the database connection string in your the `.env` file:
```
# adjust all settings in .env to your needs
APP_ENV=prod
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name
DATABASE_URL=mysql://user:password@127.0.0.1:3306/database
```
Now install all dependencies for Kimai 2:
@@ -114,6 +121,11 @@ Installation complete: enjoy time-tracking :-)
## Updating Kimai
> **NOTE**
>
> Don't forget tweaks that may be necessary to these instructions if you are using FTP, developing or updating on your
personal computer instead of a server. See [further infos about installation](var/docs/installation.md).
**STOP**
1. It's important that you don't execute the Installation steps before or after your upgrade

View File

@@ -5,7 +5,6 @@ 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:
```bash
@@ -19,6 +18,23 @@ 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.
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6) (2018-11-18)
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)
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`
- `kimai.invoice.renderer`
- `kimai.invoice.number_generator`
The new config `kimai.invoice.documents` was introduced, holding a list of directories ([read more](https://github.com/kevinpapst/kimai2/blob/master/var/docs/invoices.md)).
**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)
In the time between 0.3 and 0.4 there was a release of composer that introduced a BC break,
@@ -32,7 +48,7 @@ which leads to problems between Composer and Symfony Flex, resulting in an error
This can be fixed by updating Composer and Flex before executing the Kimai update:
```
sudo composer self-update
sudo -u www-data composer update symfony/flex --no-plugins
sudo -u www-data composer update symfony/flex --no-plugins --no-scripts
```
Then the full update can be executed as usual:

18
assets/app.js Normal file
View File

@@ -0,0 +1,18 @@
require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
require('./sass/app.scss');
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale-all');
require('fullcalendar/dist/fullcalendar.min.css');
// ------ for charts ------
require('chart.js/dist/Chart.min');
// ------ Kimai itself ------
require('./js/kimai.js');
require('./js/datatable.js');
require('./js/toolbar.js');
require('./images/default_avatar.png');
require('./images/signature.png');

View File

@@ -11,13 +11,107 @@
margin-bottom: 50px;
}
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
/* ================================ CONTENT ================================ */
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@import 'content';
@import 'toolbar';
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
}
/* ================================ NAVBAR ================================ */
.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;
}
/*
.ticktac:hover i.running{
color: #4ff131;
}
li.open .ticktac i.running{
color: #4ff131;
}
.ticktac i.stopped{
color: #fff;
}
.ticktac:hover i.stopped{
color: #4ff131;
}
.user-panel>.info {
left: 40px;
padding-top: 0px;
}
*/
/* ================================ TOOLBAR ================================ */
/* Page based action buttons in the upper right corner of the content area */
.content-header > .breadcrumb {
position: absolute;
float: right;
background: transparent;
}
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
.control-sidebar {
select {
color: $input-color;
background-color: $input-bg;
}
div.image {
img.img-circle {
max-width: 34px;
}
}
}
/* The filter form is available on most pages above the datatable */
@media (min-width: 768px) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
}
}
}
/* ================================ SIDEBAR ================================ */
/* Used for the Sidebar UserPanel (which is deactivated right now) */
@@ -32,8 +126,40 @@ footer.main-footer {
font-size: 80%;
}
@import 'invoice';
@import 'datatable';
@import 'calendar';
@import 'dashboard';
@import 'navbar';
/* ================================ PRINT ================================ */
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
/* ================================ INVOICES ================================ */
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}

View File

@@ -1,36 +0,0 @@
/*
* 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.
*/
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}

BIN
assets/images/signature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,19 +0,0 @@
require('../../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
require('../css/app.scss');
require('../css/datatable.scss');
require('../css/calendar.scss');
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale-all');
require('fullcalendar/dist/fullcalendar.min.css');
// ------ for charts ------
require('chart.js/dist/Chart.min');
// ------ Kimai itself ------
require('./kimai.js');
require('./datatable.js');
require('./toolbar.js');
require('../images/default_avatar.png');

View File

@@ -22,22 +22,26 @@ if (typeof jQuery === 'undefined') {
$.datatable = {};
$(function() {
"use strict";
"use strict";
$.datatable = {
saveVisibility: function (modalSelector) {
var settings = {};
var cookieName = $(modalSelector).find('form').attr('name');
$(modalSelector).find('form').find('input:checkbox:not(:checked)').each(
function () {
settings[$(this).attr('name')] = $(this).is(':checked');
$(modalSelector).find('form').each(
function() {
var settings = {};
var cookieName = $(this).attr('name');
$(this).find('input:checkbox').each(
function () {
settings[$(this).attr('name')] = $(this).is(':checked');
}
);
if (jQuery.isEmptyObject(settings)) {
Cookies.remove(cookieName);
} else {
Cookies.set(cookieName, JSON.stringify(settings), {expires: 365});
}
}
);
if (jQuery.isEmptyObject(settings)) {
Cookies.remove(cookieName);
} else {
Cookies.set(cookieName, JSON.stringify(settings), {expires: 365});
}
$(modalSelector).modal('toggle');
location.reload();
},
@@ -49,12 +53,12 @@ $(function() {
}
var header = $(tbl.find('th').get(column));
if (header.css("display") === "none") {
header.show();
header.show('ease');
tbl.find('tr').each(function(){
$($(this).find('td').get(column)).show('ease');
});
} else {
header.hide();
header.hide('ease');
tbl.find('tr').each(function(){
$($(this).find('td').get(column)).hide('ease');
});

View File

@@ -29,6 +29,9 @@ $(function() {
$.kimai.settings = $.extend({}, $.kimai.defaults, options);
}
// set the current locale for all javascript components
moment.locale($.kimai.settings['locale']);
// ask before a delete call is executed
$('a.btn-trash').click(function (event) {
return confirm($.kimai.settings['confirmDelete']);
@@ -51,12 +54,18 @@ $(function() {
$('input[data-datepicker="on"]').daterangepicker({
singleDatePicker: true,
showDropdowns: true,
autoUpdateInput: false,
locale: {
format: "YYYY-MM-DD",
firstDay: 1
}
});
$('input[data-datepicker="on"]').on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format('YYYY-MM-DD'));
$(this).trigger("change");
});
$('input[data-datetimepicker="on"]').daterangepicker({
singleDatePicker: true,
timePicker: true,
@@ -69,8 +78,42 @@ $(function() {
firstDay: 1
}
});
$('input[data-datetimepicker="on"]').on('apply.daterangepicker', function(ev, picker) {
$(this).val(picker.startDate.format('YYYY-MM-DD HH:mm'));
$(this).trigger("change");
});
$('select[data-related-select]').change(function() {
var apiUrl = $(this).attr('data-api-url').replace('-s-', $(this).val());
var targetSelect = $(this).attr('data-related-select');
$.ajax({
url: apiUrl,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
method: 'GET',
dataType: 'json',
success: function(data){
$('#' + targetSelect).find('option').remove().end().find('optgroup').remove().end();
$.each(data, function(i, obj) {
$('#' + targetSelect).append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
$('#' + targetSelect).trigger('change')
}
});
});
$('.markdown-body :header').prepend(function() {
$(this).prepend('<a class="anchor" href="#'+$(this).attr('id')+'"><i class="fas fa-link"></i>');
});
$('.markdown-body :header').hover(function(){
$(this).find('a.anchor').show();
}, function(){
$(this).find('a.anchor').hide();
});
/*
@@ -98,8 +141,7 @@ $(function() {
// default values
$.kimai.defaults = {
baseUrl: '/',
imagePath: '/images',
locale: 'en',
confirmDelete: 'Really delete?',
alertSuccessAutoHide: 5000
};

View File

@@ -16,17 +16,13 @@ $(document).ready(function () {
$('.toolbar form select').change(function (event) {
switch (event.target.id) {
case 'customer':
if ($(this).val() === '') {
$('.toolbar form select#project').parent().remove();
} else {
$('.toolbar form select#project').val('');
$('.toolbar form select#project').val('');
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
$('.toolbar form select#activity').parent().remove();
break;
case 'project':
if ($(this).val() === '') {
$('.toolbar form select#activity').parent().remove();
} else {
if ($('.toolbar form select#activity').find(':selected').attr('data-project')) {
$('.toolbar form select#activity').val('');
}
break;

40
assets/sass/app.scss Normal file
View File

@@ -0,0 +1,40 @@
/*
* 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.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
.error-page {
margin-bottom: 50px;
}
@media print{
.sf-toolbar, .control-sidebar {display: none !important;}
}
@import 'content';
@import 'toolbar';
/* ================================ SIDEBAR ================================ */
/* Used for the Sidebar UserPanel (which is deactivated right now) */
.user-panel>.info {
left: 80px;
}
/* ================================ FOOTER ================================ */
footer.main-footer {
padding: 5px;
font-size: 80%;
}
@import 'invoice';
@import 'datatable';
@import 'calendar';
@import 'dashboard';
@import 'navbar';
@import 'documentation';

View File

@@ -28,3 +28,14 @@
padding: 15px;
}
}
td {
&.timesheet-description {
ul {
padding-left: 15px;
}
p {
margin: 0;
}
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
.markdown-body {
h1, h2, h3, h4, h5, h6 {
a.anchor {
float: left;
font-size: 0.5em;
margin-left: -1em;
padding-top: 0.5em;
color: $text-color;
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.markdown-body {
padding-left: 25px;
}
}

304
assets/sass/invoice.scss Normal file
View File

@@ -0,0 +1,304 @@
/*
* 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.
*/
body.invoice_print
{
margin: 20px;
}
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
font: 16px/1 'Open Sans', sans-serif;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
table-layout: fixed;
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
}
header:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
header address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-weight: bold;
font-size: 0.7em;
}
article address {
font-size: 1em;
float: left;
}
article.address {
margin-bottom: 1.5em;
}
article.footer {
margin-top: 60px;
}
/* article */
article, article address, table.meta, table.inventory {
margin: 2em 0 2em;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 90%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
text-align: right;
width: 20%;
}
/* footer */
footer {
margin-top: 50px;
}
footer table {
border-top: 1px solid #000;
font-size: 0.8em;
padding-top: 5px;
}
footer th {
text-align: left;
font-weight: bold;
margin-bottom: 10px;
}
footer table th:nth-child(1) {
width: 32%;
}
footer table th:nth-child(3) {
width: 28%;
}
footer td {
text-align: left;
vertical-align: bottom;
}
/* aside */
aside {
position: absolute;
top: 10in;
width: 7.5in;
}
aside h1 {
border: none;
border-width: 0 0 1px;
margin: 0 0 1em;
}
aside h1 {
border-color: #999;
border-bottom-style: solid;
}
aside p {
font-size: 85%;
}
aside div {
margin-top: 15px;
}
aside div p {
line-height: 1.4;
}
aside p span {
width: 130px;
font-weight: bold;
display: inline-block;
}
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .invoice, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
}
#freelancer-invoice {
margin: 1em 2em;
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
}

View File

@@ -11,6 +11,7 @@
float: right;
background: transparent;
}
/* Right sidebar with additional tabs for personal settings and "About Kimai" section */
.control-sidebar {
select {
@@ -23,7 +24,18 @@
}
}
}
/* The filter form available on most pages above the datatable */
.toolbar form {
font-size: $font-size-base;
/* The filter form is available on most pages above the datatable */
@media (min-width: $screen-sm-min) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
}
}
}

View File

@@ -7,15 +7,17 @@
"php": "^7.1.3",
"ext-intl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "^2.0",
"kevinpapst/adminlte-bundle": "~2.1",
"nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1",
"phpoffice/phpspreadsheet": "^1.4",
"phpoffice/phpword": "^0.15.0",
"sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0",
"symfony/console": "^4.0",
@@ -24,7 +26,6 @@
"symfony/flex": "^1.0",
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/lts": "^4@dev",
"symfony/monolog-bundle": "^3.1",
"symfony/orm-pack": "^1.0",
"symfony/profiler-pack": "^1.0",
@@ -95,7 +96,8 @@
"extra": {
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": true
"allow-contrib": true,
"require": "~4.1.0"
}
}
}

1044
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ admin_lte:
# https://github.com/kevinpapst/AdminLTEBundle/blob/master/Resources/docs/control_sidebar.md
control_sidebar:
settings:
icon: "fas fa-cogs"
icon: "fas fa-user"
controller: 'App\Controller\SidebarController::settingsAction'
home:
icon: "fas fa-question-circle"
@@ -31,7 +31,7 @@ admin_lte:
routes:
adminlte_profile: user_profile
adminlte_welcome: dashboard
adminlte_welcome: homepage
adminlte_login: fos_user_security_login
adminlte_login_check: fos_user_security_check
adminlte_registration: fos_user_registration_register

View File

@@ -1,16 +1,16 @@
fos_rest:
# disable_csrf_role: ROLE_API
# disable_csrf_role: IS_AUTHENTICATED_ANONYMOUSLY
# access_denied_listener:
# enabled: false
# service: null
# formats:
# name: ~
# unauthorized_challenge: null
# param_fetcher_listener:
# enabled: false
param_fetcher_listener:
enabled: true
# force: false
# service: null
# cache_dir: '%kernel.cache_dir%/fos_rest'
cache_dir: '%kernel.cache_dir%/fos_rest'
# allowed_methods_listener:
# enabled: false
# service: null
@@ -18,8 +18,8 @@ fos_rest:
default_format: json
include_format: true
# prefix_methods: true
# body_converter:
# enabled: false
body_converter:
enabled: true
# validate: false
# validation_errors_argument: validationErrors
# service:
@@ -46,8 +46,8 @@ fos_rest:
# name: ~
# templating_formats:
# name: ~
# view_response_listener:
# enabled: true
view_response_listener:
enabled: true
# force: true
# service: null
failed_validation: 400
@@ -67,16 +67,16 @@ fos_rest:
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': true
# body_listener:
# enabled: true
body_listener:
enabled: true
# service: null
# default_format: null
# throw_exception_on_unsupported_content_type: false
# decoders:
# name: ~
# array_normalizer:
# service: null
# forms: false
array_normalizer:
service: null
forms: true
format_listener:
enabled: true
# service: null

View File

@@ -1,4 +1,7 @@
jms_serializer:
handlers:
datetime:
default_format: "Y-m-d H:i"
visitors:
xml:
format_output: '%kernel.debug%'

View File

@@ -8,6 +8,9 @@ kimai:
# All configs related to timesheet and record management
timesheet:
# render timesheet descriptions with markdown
markdown_content: false
# Whether we display start and end time columns (false) or durations only (true).
# Setting this to true will also change the "edit timesheet" forms, more infos available in the configurations docu.
duration_only: false
@@ -30,16 +33,12 @@ kimai:
# days: ['saturday','sunday']
# factor: 1.5
# All configs related to the invoice administration
invoice:
renderer:
default: 'App\Controller\InvoiceController::invoiceAction'
timesheet: 'App\Controller\InvoiceController::timesheetAction'
calculator:
default: 'App\Invoice\DefaultCalculator'
short: 'App\Invoice\ShortInvoiceCalculator'
number_generator:
default: 'App\Invoice\DateNumberGenerator'
# Invoice management
#invoice:
# # all files in these directories will be used as invoice documents (if supported by a renderer)
# documents:
# - 'var/invoices/'
# - 'templates/invoice/renderer/'
# Language specific settings, like the date formats
languages:
@@ -58,7 +57,7 @@ kimai:
fr:
date_short: 'd/m/Y'
duration: '%%h h %%m min'
duration_short: '%%h:%%m h'
duration_short: '%%h h %%m'
es:
date_short: 'd.m.Y'
duration: '%%h:%%m h'
@@ -67,6 +66,14 @@ kimai:
date_short: 'd.m.Y'
duration: '%%h:%%m Часов'
duration_short: '%%h:%%m h'
ar:
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
hu:
date_short: 'Y.m.d.'
duration: '%%h:%%m óra'
duration_short: '%%h:%%m h'
# timesheet calendar configuration
calendar:
@@ -89,6 +96,9 @@ kimai:
# theme related settings, will be available as twig globals at "kimai_context.*"
# please see documentation at var/docs/theme.md
theme:
# BETA test: If you set this to 'selectpicker' the customer/project/activity select boxes will be transformed
# into a searchable and javascript enhanced input type
select_type: ~
# display a warning color if the user has at least X active recordings
active_warning: 3
# fallback color for all widgets that don't have a dedicated color
@@ -151,3 +161,10 @@ kimai:
activeUsersYear: { title: stats.userActiveYear, query: users, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: user, color: yellow }
activeUsersTotal: { title: stats.userActiveTotal, query: users, icon: user, color: red }
activeRecordings: { title: stats.activeRecordings, query: active, icon: duration, color: red }
# Default settings used to populate forms
#defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR

View File

@@ -1,16 +1,35 @@
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] }
areas:
path_patterns:
- ^/api(?!/doc)
documentation:
#host: '{{hostname}}'
# host: '{{hostname}}'
schemes: [http, https]
info:
title: Kimai 2 - API Docs
description: REST API description for the Kimai 2 time-tracking software
version: 0.1
description: REST API description for the Kimai 2 time-tracking software. Do not rely on the example values/models, they are currently generated wrong. Please fetch the request and response structure from the API itself.
version: 0.2
# parameters:
# hostname:
# name: hostname
# in: url
# description: the hostname where your api runs
# required: true
# type: string
securityDefinitions:
X-AUTH-USER:
type: apiKey

View File

@@ -4,10 +4,3 @@ home:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: homepage
permanent: true
homepage:
path: /{_locale}
defaults:
_controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::redirectAction
route: timesheet
permanent: true

View File

@@ -0,0 +1,15 @@
app.test.activity_recent:
path: /{_locale}/activities/recent
controller: App\Controller\ActivityController::recentActivitiesAction
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'
app.test.sidebar_settings:
path: /{_locale}/sidebar/settings
controller: App\Controller\SidebarController::settingsAction
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'

View File

@@ -1,18 +1,32 @@
App\Entity\Activity:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project_id]
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
fixedRate:
include: true
groups: [Default]
hourlyRate:
include: true
groups: [Default]
project:
include: false
exclude: true
groups: [Default]
virtual_properties:
getProjectId:
name: project_id
serialized_name: project_id
getProject:
serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer
groups: [Default]

View File

@@ -1,35 +1,58 @@
App\Entity\Customer:
exclusion_policy: All
custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
number:
include: true
groups: [Entity]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
company:
include: true
groups: [Entity]
contact:
include: true
groups: [Entity]
address:
include: true
groups: [Entity]
country:
include: true
groups: [Entity]
currency:
include: true
groups: [Entity]
phone:
include: true
groups: [Entity]
fax:
include: true
groups: [Entity]
mobile:
include: true
groups: [Entity]
mail:
include: true
groups: [Entity]
homepage:
include: true
groups: [Entity]
timezone:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Default]
hourlyRate:
include: true
groups: [Default]

View File

@@ -1,22 +1,38 @@
App\Entity\Project:
exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer_id]
custom_accessor_order: [id, name, comment, visible, budget, orderNumber, customer, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
name:
include: true
groups: [Default]
comment:
include: true
groups: [Entity]
visible:
include: true
groups: [Default]
budget:
include: true
groups: [Entity]
orderNumber:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Entity]
hourlyRate:
include: true
groups: [Entity]
customer:
include: false
exclude: true
groups: [Default]
virtual_properties:
getCustomerId:
name: customer_id
serialized_name: customer_id
getCustomer:
serialized_name: customer
exp: "object.getCustomer() === null ? null : object.getCustomer().getId()"
type: integer
groups: [Default]

View File

@@ -0,0 +1,56 @@
App\Entity\Timesheet:
exclusion_policy: All
custom_accessor_order: [id, begin, end, duration, rate, activity, project, user, description, fixedRate, hourlyRate]
properties:
id:
include: true
groups: [Default]
begin:
include: true
groups: [Default]
end:
include: true
groups: [Default]
duration:
include: true
groups: [Default]
description:
include: true
groups: [Entity]
rate:
include: true
groups: [Default]
fixedRate:
include: true
groups: [Entity]
hourlyRate:
include: true
groups: [Entity]
activity:
include: false
exclude: true
groups: [Default]
project:
include: false
exclude: true
groups: [Default]
user:
include: false
exclude: true
groups: [Default]
virtual_properties:
getActivity:
serialized_name: activity
exp: "object.getActivity() === null ? null : object.getActivity().getId()"
type: integer
groups: [Default]
getProject:
serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer
groups: [Default]
getUser:
serialized_name: user
exp: "object.getUser().getId()"
type: integer
groups: [Default]

View File

@@ -3,9 +3,13 @@ App\Entity\User:
properties:
id:
include: true
groups: [Default]
alias:
include: true
groups: [Default]
title:
include: true
groups: [Entity]
avatar:
include: true
groups: [Entity]

View File

@@ -1,6 +1,15 @@
FOS\UserBundle\Model\User:
exclusion_policy: NONE
properties:
enabled:
include: true
groups: [Default]
groups:
include: true
groups: [Entity]
roles:
include: true
groups: [Entity]
email:
exclude: true
emailCanonical:

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: en|de|it|fr|es|ru
app_locales: en|de|it|fr|es|ru|ar|hu
services:
# default configuration for services in *this* file
@@ -32,15 +32,8 @@ services:
# APPLICATION CORE
# ================================================================================
App\Invoice\ServiceInvoice:
class: App\Invoice\ServiceInvoice
arguments: ['%kimai.invoice%']
App\EventSubscriber\RedirectToLocaleSubscriber:
class: App\EventSubscriber\RedirectToLocaleSubscriber
arguments: ['@router', '%app_locales%', '%locale%']
tags:
- { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
App\Calendar\Service:
arguments:
@@ -54,6 +47,14 @@ services:
arguments:
$dashboard: "%kimai.dashboard%"
App\Twig\MarkdownExtension:
arguments:
$timesheetAsMarkdown: "%kimai.timesheet.markdown%"
App\Controller\Admin\CustomerController:
arguments:
$defaults: "%kimai.defaults%"
# ================================================================================
# DATABASE
# ================================================================================
@@ -77,17 +78,21 @@ services:
# FORMS
# ================================================================================
# form type to edit user roles
App\Form\Type\UserRoleType:
class: App\Form\Type\UserRoleType
arguments: ["%security.role_hierarchy.roles%"]
tags:
- { name: form.type }
App\Form\Type\LanguageType:
arguments: ["%app_locales%"]
App\Form\Extension\DocumentationLinkExtension:
tags:
- { name: form.type_extension, extended_type: Symfony\Component\Form\Extension\Core\Type\FormType }
App\Form\Extension\EnhancedChoiceTypeExtension:
arguments: ["%kimai.theme.select_type%"]
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
# ================================================================================
# THEME
# ================================================================================
@@ -141,3 +146,13 @@ services:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Customer']
App\Repository\InvoiceTemplateRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\InvoiceTemplate']
App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%']

View File

@@ -4,7 +4,7 @@ App\Entity\User:
- App\Validator\Constraints\Role: ~
username:
- NotBlank: ~
- Length: { min: 5, max: 60 }
- Length: { min: 3, max: 60 }
email:
- NotBlank: ~
- Email: ~

View File

@@ -17,7 +17,7 @@
<env name="DATABASE_PREFIX" value="kimai2_" force="true"/>
<!-- define your env variables for the test env here -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak_vendors"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
<!-- ###+ doctrine/doctrine-bundle ### -->
<env name="DATABASE_URL" value="sqlite:///var/data/kimai_test.sqlite" force="true"/>
@@ -39,9 +39,11 @@
<directory suffix=".php">src/</directory>
<directory suffix=".php">templates/</directory>
<exclude>
<directory suffix=".php">src/Migrations/</directory>
<directory suffix=".php">assets/</directory>
<directory suffix=".php">bin/</directory>
<directory suffix=".php">config/</directory>
<directory suffix=".php">node_modules/</directory>
<directory suffix=".php">public/</directory>
<directory suffix=".php">tests/</directory>
<directory suffix=".php">translations/</directory>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,8 +1,8 @@
{
"build/app.js": "/build/app.js?a67489c4fc07f30e6f86",
"build/app.css": "/build/app.css?e2bd74ce6119917ab0c8fd4de8d4b9c6",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/app.js": "/build/app.js?d823754f6c3293656052",
"build/app.css": "/build/app.css?979d88ed847886068fcf3eb0bc3de545",
"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",
"build/images/fa-solid-900.svg": "/build/images/fa-solid-900.svg?666a82cb",
"build/images/glyphicons-halflings-regular.svg": "/build/images/glyphicons-halflings-regular.svg?89889688",
@@ -13,7 +13,7 @@
"build/fonts/fa-brands-400.woff": "/build/fonts/fa-brands-400.woff?e9f83339",
"build/fonts/glyphicons-halflings-regular.woff2": "/build/fonts/glyphicons-halflings-regular.woff2?448c34a5",
"build/fonts/fa-solid-900.eot": "/build/fonts/fa-solid-900.eot?0b93480e",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/fonts/fa-regular-400.woff2": "/build/fonts/fa-regular-400.woff2?930c1264",
"build/images/fa-regular-400.svg": "/build/images/fa-regular-400.svg?4e41caaf",
@@ -23,5 +23,6 @@
"build/fonts/fa-brands-400.woff2": "/build/fonts/fa-brands-400.woff2?66f625f1",
"build/fonts/glyphicons-halflings-regular.ttf": "/build/fonts/glyphicons-halflings-regular.ttf?e18bbf61",
"build/images/fa-brands-400.svg": "/build/images/fa-brands-400.svg?5da7e33c",
"build/images/default_avatar.png": "/build/images/default_avatar.png?fc197350"
"build/images/default_avatar.png": "/build/images/default_avatar.png?fc197350",
"build/images/signature.png": "/build/images/signature.png?5f58501e"
}

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation as API;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends Controller
class ActivityController extends BaseApiController
{
/**
* @var ActivityRepository
@@ -51,26 +52,46 @@ class ActivityController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* response=200,
* description="Returns the collection of all existing activities",
* @SWG\Schema(ref="#/definitions/ActivityCollection"),
* )
* @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)")
* @Rest\QueryParam(name="globals", requirements="true", strict=true, nullable=true, description="Pass 'true' as string to fetch only global activities")
*
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ActivityQuery();
$query->setOrderGlobalsFirst(true)
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS);
if (null !== ($globals = $paramFetcher->get('globals'))) {
$query->setGlobalsOnly(true);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Activity']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one activity entity",
* @SWG\Schema(ref=@API\Model(type=Activity::class)),
* response=200,
* description="Returns one activity entity",
* @SWG\Schema(ref="#/definitions/ActivityEntity"),
* )
*
* @param int $id
@@ -83,6 +104,7 @@ class ActivityController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Activity']);
return $this->viewHandler->handle($view);
}

View File

@@ -0,0 +1,18 @@
<?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 App\API;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
abstract class BaseApiController extends Controller
{
}

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Customer;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class CustomerController extends Controller
class CustomerController extends BaseApiController
{
/**
* @var CustomerRepository
@@ -51,26 +52,35 @@ class CustomerController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* response=200,
* description="Returns the collection of all existing customer",
* @SWG\Schema(ref="#/definitions/CustomerCollection"),
* )
* @Rest\QueryParam(name="visible", requirements="\d+", strict=true, nullable=true, description="Visibility status to filter activities (1=visible, 2=hidden, 3=both)")
*
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new CustomerQuery();
$query->setResultType(CustomerQuery::RESULT_TYPE_OBJECTS);
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Customer']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one customer entity",
* @SWG\Schema(ref=@Model(type=Customer::class)),
* response=200,
* description="Returns one customer entity",
* @SWG\Schema(ref="#/definitions/CustomerEntity"),
* )
*
* @param int $id
@@ -83,6 +93,7 @@ class CustomerController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Customer']);
return $this->viewHandler->handle($view);
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace App\API;
use App\Constants;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
@@ -36,6 +37,7 @@ class HealthcheckController extends Controller
* @SWG\Response(
* response=200,
* description="A simple route that returns a 'pong', which you can use for testing the API",
* examples={"{'message': 'pong'}"}
* )
*
* @Rest\Get(path="/ping")
@@ -46,4 +48,25 @@ class HealthcheckController extends Controller
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns version information about the current release",
* )
*
* @Rest\Get(path="/version")
*/
public function versionAction()
{
$version = [
'version' => Constants::VERSION,
'candidate' => Constants::STATUS,
'semver' => Constants::VERSION . '-' . Constants::STATUS,
'name' => Constants::NAME,
'copyright' => 'Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.',
];
return $this->viewHandler->handle(new View($version, 200));
}
}

View File

@@ -13,13 +13,14 @@ namespace App\API;
use App\Entity\Project;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -27,7 +28,7 @@ use Symfony\Component\HttpFoundation\Response;
*
* @Security("is_granted('ROLE_USER')")
*/
class ProjectController extends Controller
class ProjectController extends BaseApiController
{
/**
* @var ProjectRepository
@@ -51,26 +52,41 @@ class ProjectController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref=@Model(type=Project::class)),
* response=200,
* description="Returns the collection of all existing projects",
* @SWG\Schema(ref="#/definitions/ProjectCollection"),
* )
* @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)")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
*/
public function cgetAction()
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$data = $this->repository->findAll();
$query = new ProjectQuery();
$query->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS);
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible);
}
$data = $this->repository->findByQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Project']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one project entity",
* @SWG\Schema(ref=@Model(type=Project::class)),
* response=200,
* description="Returns one project entity",
* @SWG\Schema(ref="#/definitions/ProjectEntity"),
* )
*
* @param int $id
@@ -83,6 +99,7 @@ class ProjectController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Project']);
return $this->viewHandler->handle($view);
}

View File

@@ -0,0 +1,191 @@
<?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 App\API;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\Request\ParamFetcherInterface;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Timesheet")
*
* @Security("is_granted('ROLE_USER')")
*/
class TimesheetController extends BaseApiController
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all existing timesheets for the user",
* @SWG\Schema(ref="#/definitions/TimesheetCollection"),
* )
* @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")
* @Rest\QueryParam(name="page", requirements="\d+", strict=true, nullable=true, description="The page to display, renders a 404 if not found (default: 1)")
* @Rest\QueryParam(name="size", requirements="\d+", strict=true, nullable=true, description="The amount of entries for each page (default: 25)")
* @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')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new TimesheetQuery();
$query->setUser($this->getUser());
$query->setResultType(TimesheetQuery::RESULT_TYPE_PAGER);
if (null !== ($customer = $paramFetcher->get('customer'))) {
$query->setCustomer($customer);
}
if (null !== ($project = $paramFetcher->get('project'))) {
$query->setProject($project);
}
if (null !== ($activity = $paramFetcher->get('activity'))) {
$query->setActivity($activity);
}
if (null !== ($page = $paramFetcher->get('page'))) {
$query->setPage($page);
}
if (null !== ($size = $paramFetcher->get('size'))) {
$query->setPageSize($size);
}
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
/** @var Pagerfanta $data */
$data = $this->repository->findByQuery($query);
$data = (array) $data->getCurrentPageResults();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'Timesheet']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Returns one timesheet entity",
* @SWG\Schema(ref="#/definitions/TimesheetEntity")
* )
*
* @param int $id
* @return Response
*/
public function getAction($id)
{
$data = $this->repository->find($id);
if (null === $data) {
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
/**
* @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"),
* )
* )
*
* @param Request $request
* @return Response
*/
public function postAction(Request $request)
{
$timesheet = new Timesheet();
$timesheet->setUser($this->getUser());
$timesheet->setBegin(new \DateTime());
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'method' => 'POST',
'duration_only' => false,
]);
$form->setData($timesheet);
$form->submit($request->request->all());
if ($form->isValid()) {
if (null !== $timesheet->getId()) {
return new Response('This method does not support updates', Response::HTTP_BAD_REQUEST);
}
if (!$this->isGranted('start', $timesheet)) {
return new Response('You are not allowed to start this timesheet record', Response::HTTP_BAD_REQUEST);
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
$view = new View($form);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -16,10 +16,8 @@ use App\Repository\UserRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Nelmio\ApiDocBundle\Annotation\Model;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
@@ -28,7 +26,7 @@ use Symfony\Component\HttpFoundation\Response;
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
*/
class UserController extends Controller
class UserController extends BaseApiController
{
/**
* @var UserRepository
@@ -52,9 +50,9 @@ class UserController extends Controller
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref=@Model(type=User::class)),
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref="#/definitions/UserCollection"),
* )
*
* @return Response
@@ -63,15 +61,16 @@ class UserController extends Controller
{
$data = $this->repository->findAll();
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'User']);
return $this->viewHandler->handle($view);
}
/**
* @SWG\Response(
* response=200,
* description="Return one user entity",
* @SWG\Schema(ref=@Model(type=User::class)),
* response=200,
* description="Return one user entity",
* @SWG\Schema(ref="#/definitions/UserEntity"),
* )
*
* @param int $id
@@ -84,6 +83,7 @@ class UserController extends Controller
throw new NotFoundException();
}
$view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'User']);
return $this->viewHandler->handle($view);
}

View File

@@ -63,8 +63,8 @@ class TimesheetEntity
$this->start = $entry->getBegin();
$this->title = $entry->getActivity()->getName();
$this->description = $entry->getDescription();
$this->customer = $entry->getActivity()->getProject()->getCustomer()->getName();
$this->project = $entry->getActivity()->getProject()->getName();
$this->customer = $entry->getProject()->getCustomer()->getName();
$this->project = $entry->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (null === $entry->getEnd()) {

View File

@@ -111,7 +111,7 @@ class CreateUserCommand extends Command
$pwd = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($pwd);
$errors = $this->validator->validate($user);
$errors = $this->validator->validate($user, null, ['registration']);
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
@@ -149,13 +149,13 @@ class CreateUserCommand extends Command
/* @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$passwordQuestion = new Question('Please enter the password');
$passwordQuestion = new Question('Please enter the password: ');
$passwordQuestion->setHidden(true);
$passwordQuestion->setHiddenFallback(false);
$passwordQuestion->setValidator(function (?string $value) {
$password = trim($value);
if (empty($password) || strlen($password) < 6) {
throw new \Exception('The password is too short, must be at least 6 character');
if (empty($password)) {
throw new \Exception('The password may not be empty');
}
return $value;

View File

@@ -81,15 +81,14 @@ class KimaiImporterCommand extends Command
* @var Activity[]
*/
protected $activities = [];
/**
* activityId => activity[]
* @var array
*/
protected $unassignedActivities = [];
/**
* @var bool
*/
protected $debug = false;
/**
* @var array
*/
protected $oldActivities = [];
/**
* @param UserPasswordEncoderInterface $encoder
@@ -168,6 +167,8 @@ class KimaiImporterCommand extends Command
$activities = null;
$records = null;
$activityToProject = null;
$fixedRates = null;
$rates = null;
$bytesStart = memory_get_usage(true);
@@ -219,6 +220,22 @@ class KimaiImporterCommand extends Command
return;
}
try {
$fixedRates = $this->fetchAllFromImport('fixedRates');
} catch (\Exception $ex) {
$io->error('Failed to load fixedRates: ' . $ex->getMessage());
return;
}
try {
$rates = $this->fetchAllFromImport('rates');
} catch (\Exception $ex) {
$io->error('Failed to load rates: ' . $ex->getMessage());
return;
}
$bytesCached = memory_get_usage(true);
$io->success('Fetched Kimai v1 data, trying to import now ...');
@@ -246,7 +263,7 @@ class KimaiImporterCommand extends Command
}
try {
$counter = $this->importProjects($io, $projects);
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported projects: ' . $counter);
} catch (\Exception $ex) {
@@ -256,7 +273,7 @@ class KimaiImporterCommand extends Command
}
try {
$counter = $this->importActivities($io, $activities, $activityToProject);
$counter = $this->importActivities($io, $activities, $activityToProject, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported activities: ' . $counter);
} catch (\Exception $ex) {
@@ -266,7 +283,7 @@ class KimaiImporterCommand extends Command
}
try {
$counter = $this->importTimesheetRecords($io, $records);
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported timesheet records: ' . $counter);
} catch (\Exception $ex) {
@@ -275,10 +292,6 @@ class KimaiImporterCommand extends Command
return;
}
// TODO support fixedRates (projectID, activityID, rate)
// TODO support rates (userID, projectID, activityID, rate)
// TODO dump yaml config from configuration (adminmail, currency_name, date_format_0, language, roundPrecision)
// TODO support preferences (ui.lang, timezone)
// TODO support expenses - new database required
$bytesImported = memory_get_usage(true);
@@ -294,22 +307,32 @@ class KimaiImporterCommand extends Command
}
/**
* Checks if the ghiven database connection for import has an underlying database with a compatible structure.
* This is checked againstto the Kimai version and database revision.
* Checks if the given database connection for import has an underlying database with a compatible structure.
* This is checked against the Kimai version and database revision.
*
* @param SymfonyStyle $io
* @param $requiredVersion
* @param $requiredRevision
* @param string $requiredVersion
* @param string $requiredRevision
* @return bool
* @throws \Doctrine\DBAL\DBALException
*/
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{
$versionQuery = 'SELECT `value` from ' . $this->dbPrefix . 'configuration WHERE `option` = "version"';
$revisionQuery = 'SELECT `value` from ' . $this->dbPrefix . 'configuration WHERE `option` = "revision"';
$version = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'version')
->execute()
->fetchColumn();
$version = $this->getImportConnection()->query($versionQuery)->fetchColumn();
$revision = $this->getImportConnection()->query($revisionQuery)->fetchColumn();
$revision = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'revision')
->execute()
->fetchColumn();
if (1 == version_compare($requiredVersion, $version)) {
$io->error(
@@ -364,21 +387,16 @@ class KimaiImporterCommand extends Command
}
/**
* @param $table
* @param string $table
* @return array
* @throws \Doctrine\DBAL\DBALException
*/
protected function fetchAllFromImport($table)
{
return $this->getImportConnection()->query('SELECT * from ' . $this->dbPrefix . $table)->fetchAll();
}
/**
* @return \Doctrine\DBAL\Connection
*/
protected function getImportConnection()
{
return $this->connection;
return $this->connection->createQueryBuilder()
->select('*')
->from($this->connection->quoteIdentifier($this->dbPrefix . $table))
->execute()
->fetchAll();
}
/**
@@ -508,8 +526,8 @@ class KimaiImporterCommand extends Command
* ["phone"]=> NULL
* ["fax"]=> NULL
* ["mobile"]=> NULL
* --- ["mail"]=> NULL
* --- ["homepage"]=> NULL
* ["mail"]=> NULL
* ["homepage"]=> NULL
* ["trash"]=> string(1) "0"
* ["timezone"]=> string(13) "Europe/Berlin"
*
@@ -538,7 +556,9 @@ class KimaiImporterCommand extends Command
->setComment($oldCustomer['comment'])
->setCompany($oldCustomer['company'])
->setFax($oldCustomer['fax'])
->setHomepage($oldCustomer['homepage'])
->setMobile($oldCustomer['mobile'])
->setMail($oldCustomer['mail'])
->setPhone($oldCustomer['phone'])
->setContact($oldCustomer['contact'])
->setAddress($oldCustomer['street'] . PHP_EOL . $oldCustomer['zipcode'] . ' ' . $oldCustomer['city'])
@@ -586,10 +606,12 @@ class KimaiImporterCommand extends Command
*
* @param SymfonyStyle $io
* @param array $projects
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
*/
protected function importProjects(SymfonyStyle $io, $projects)
protected function importProjects(SymfonyStyle $io, $projects, array $fixedRates, array $rates)
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
@@ -611,6 +633,24 @@ class KimaiImporterCommand extends Command
->setVisible($isActive)
;
foreach ($fixedRates as $fixedRow) {
if ($fixedRow['activityID'] !== null || $fixedRow['projectID'] === null) {
continue;
}
if ($fixedRow['projectID'] == $oldProject['projectID']) {
$project->setFixedRate($fixedRow['rate']);
}
}
foreach ($rates as $ratesRow) {
if ($ratesRow['userID'] !== null || $ratesRow['activityID'] !== null || $ratesRow['projectID'] === null) {
continue;
}
if ($ratesRow['projectID'] == $oldProject['projectID']) {
$project->setHourlyRate($ratesRow['rate']);
}
}
if (!$this->validateImport($io, $project)) {
throw new \Exception('Failed to validate project: ' . $project->getName());
}
@@ -654,23 +694,41 @@ class KimaiImporterCommand extends Command
* @param SymfonyStyle $io
* @param array $activities
* @param array $activityToProject
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
*/
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject)
protected function importActivities(SymfonyStyle $io, array $activities, array $activityToProject, array $fixedRates, array $rates)
{
$counter = 0;
$entityManager = $this->getDoctrine()->getManager();
// remember which activity has at least one assigned project
$oldActivityMapping = [];
foreach ($activityToProject as $mapping) {
$oldActivityMapping[$mapping['activityID']] = $mapping['projectID'];
$oldActivityMapping[$mapping['activityID']][] = $mapping['projectID'];
}
// create global activities
foreach ($activities as $oldActivity) {
$this->oldActivities[$oldActivity['activityID']] = $oldActivity;
if (isset($oldActivityMapping[$oldActivity['activityID']])) {
$projectId = $oldActivityMapping[$oldActivity['activityID']];
$project = null;
continue;
}
$this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, null);
++$counter;
}
$io->success('Created global activities: ' . $counter);
// create project specific activities
foreach ($activities as $oldActivity) {
if (!isset($oldActivityMapping[$oldActivity['activityID']])) {
continue;
}
foreach ($oldActivityMapping[$oldActivity['activityID']] as $projectId) {
if (!isset($this->projects[$projectId])) {
throw new \Exception(
'Invalid project linked to activity ' . $oldActivity['name'] . ': ' . $projectId
@@ -679,11 +737,8 @@ class KimaiImporterCommand extends Command
$project = $this->projects[$projectId];
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
$this->createActivity($io, $entityManager, $project, $oldActivity);
$this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $project);
++$counter;
} else {
$this->unassignedActivities[$oldActivity['activityID']] = $oldActivity;
}
}
@@ -693,20 +748,26 @@ class KimaiImporterCommand extends Command
/**
* @param SymfonyStyle $io
* @param ObjectManager $entityManager
* @param Project $project
* @param array $oldActivity
* @param array $fixedRates
* @param array $rates
* @param Project $project
* @return Activity
* @throws \Exception
*/
protected function createActivity(
SymfonyStyle $io,
ObjectManager $entityManager,
Project $project,
array $oldActivity
array $oldActivity,
array $fixedRates,
array $rates,
?Project $project
) {
$activityId = $oldActivity['activityID'];
if (isset($this->activities[$activityId][$project->getId()])) {
return $this->activities[$activityId][$project->getId()];
$projectId = null !== $project ? $project->getId() : null;
if (isset($this->activities[$activityId][$projectId])) {
return $this->activities[$activityId][$projectId];
}
$isActive = (bool) $oldActivity['visible'] && !(bool) $oldActivity['trash'];
@@ -724,6 +785,32 @@ class KimaiImporterCommand extends Command
->setProject($project)
;
foreach ($fixedRates as $fixedRow) {
if ($fixedRow['activityID'] === null) {
continue;
}
if ($fixedRow['projectID'] !== null && $fixedRow['projectID'] !== $projectId) {
continue;
}
if ($fixedRow['activityID'] == $oldActivity['activityID']) {
$activity->setFixedRate($fixedRow['rate']);
}
}
foreach ($rates as $ratesRow) {
if ($ratesRow['userID'] !== null || $ratesRow['activityID'] === null) {
continue;
}
if ($ratesRow['projectID'] !== null && $ratesRow['projectID'] !== $projectId) {
continue;
}
if ($ratesRow['activityID'] == $oldActivity['activityID']) {
$activity->setHourlyRate($ratesRow['rate']);
}
}
if (!$this->validateImport($io, $activity)) {
throw new \Exception('Failed to validate activity: ' . $activity->getName());
}
@@ -742,7 +829,7 @@ class KimaiImporterCommand extends Command
if (!isset($this->activities[$activityId])) {
$this->activities[$activityId] = [];
}
$this->activities[$activityId][$project->getId()] = $activity;
$this->activities[$activityId][$projectId] = $activity;
return $activity;
}
@@ -772,14 +859,19 @@ class KimaiImporterCommand extends Command
*
* @param SymfonyStyle $io
* @param array $records
* @param array $fixedRates
* @param array $rates
* @return int
* @throws \Exception
*/
protected function importTimesheetRecords(SymfonyStyle $io, array $records)
protected function importTimesheetRecords(SymfonyStyle $io, array $records, array $fixedRates, array $rates)
{
$counter = 0;
$activityCounter = 0;
$entityManager = $this->getDoctrine()->getManager();
$total = count($records);
$io->writeln('Importing timesheets, please wait');
foreach ($records as $oldRecord) {
$activity = null;
@@ -794,31 +886,47 @@ class KimaiImporterCommand extends Command
continue;
}
$customerId = $project->getCustomer()->getId();
if (isset($this->activities[$activityId][$projectId])) {
$activity = $this->activities[$activityId][$projectId];
} elseif (isset($this->activities[$activityId][null])) {
$activity = $this->activities[$activityId][null];
}
if (null === $activity && isset($this->unassignedActivities[$activityId])) {
$oldActivity = $this->unassignedActivities[$activityId];
$activity = $this->createActivity($io, $entityManager, $project, $oldActivity);
if (null === $activity && isset($this->oldActivities[$activityId])) {
$oldActivity = $this->oldActivities[$activityId];
$activity = $this->createActivity($io, $entityManager, $oldActivity, $fixedRates, $rates, $project);
++$activityCounter;
}
if (null === $activity) {
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId);
$io->error('Could not create timesheet record, missing activity with ID: ' . $activityId . '/' . $projectId . '/' . $customerId);
continue;
}
$duration = $oldRecord['end'] - $oldRecord['start'];
$rate = $oldRecord['fixedRate'];
if ((empty($rate) || 0.00 == $rate) && !empty($oldRecord['rate'])) {
$hourlyRate = (float) $oldRecord['rate'];
$rate = (float) $hourlyRate * ($duration / 3600);
$rate = round($rate, 2);
$timesheet = new Timesheet();
$fixedRate = $oldRecord['fixedRate'];
if (!empty($fixedRate) && 0.00 != $fixedRate) {
$timesheet->setFixedRate($fixedRate);
}
$hourlyRate = $oldRecord['rate'];
if (!empty($hourlyRate) && 0.00 != $hourlyRate) {
$timesheet->setHourlyRate($hourlyRate);
}
if ($timesheet->getFixedRate() !== null) {
$timesheet->setRate($timesheet->getFixedRate());
} elseif ($timesheet->getHourlyRate() !== null) {
$hourlyRate = $timesheet->getHourlyRate();
$rate = (float) $hourlyRate * ($duration / 3600);
$timesheet->setRate(round($rate, 2));
}
$timesheet = new Timesheet();
$timesheet
->setDescription($oldRecord['description'] ?: ($oldRecord['comment'] ?: null))
->setUser($this->users[$oldRecord['userID']])
@@ -826,7 +934,7 @@ class KimaiImporterCommand extends Command
->setEnd(new \DateTime('@' . $oldRecord['end']))
->setDuration($duration)
->setActivity($activity)
->setRate($rate)
->setProject($project)
;
if (!$this->validateImport($io, $timesheet)) {
@@ -845,13 +953,20 @@ class KimaiImporterCommand extends Command
$io->error('Reason: ' . $ex->getMessage());
}
if (0 == $counter % 500) {
$io->writeln('Imported ' . $counter . ' timesheet records, import ongoing ...');
$io->write('.');
if (0 == $counter % 80) {
$io->writeln(' (' . $counter . '/' . $total . ')');
$entityManager->clear(Timesheet::class);
}
}
for ($i = 0; $i < 80 - ($counter % 80); $i++) {
$io->write(' ');
}
$io->writeln(' (' . $counter . '/' . $total . ')');
if ($activityCounter > 0) {
$io->success('Created new (previously unattached) activities during timesheet import: ' . $activityCounter);
$io->success('Created new activities during timesheet import: ' . $activityCounter);
}
return $counter;

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Constants;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Command used to fetch Kimai version information.
*/
class VersionCommand extends Command
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:version')
->setDescription('Receive version information')
->setHelp('This command allows you to fetch various version information about Kimai.')
->addOption('name', null, InputOption::VALUE_NONE, 'Display the major release name')
->addOption('candidate', null, InputOption::VALUE_NONE, 'Display the current version candidate (e.g. "stable" or "dev")')
->addOption('short', null, InputOption::VALUE_NONE, 'Display the version only')
->addOption('semver', null, InputOption::VALUE_NONE, 'Semantical versioning (SEMVER) compatible version string')
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
if ($input->getOption('semver')) {
$io->writeln(Constants::VERSION . '-' . Constants::STATUS);
return;
}
if ($input->getOption('short')) {
$io->writeln(Constants::VERSION);
return;
}
if ($input->getOption('name')) {
$io->writeln(Constants::NAME);
return;
}
if ($input->getOption('candidate')) {
$io->writeln(Constants::STATUS);
return;
}
$io->writeln('Kimai 2 - ' . Constants::VERSION . ' ' . Constants::STATUS . ' (' . Constants::NAME . ') by Kevin Papst and contributors.');
}
}

View File

@@ -15,11 +15,23 @@ namespace App;
class Constants
{
/**
* Currently only used for informational purpose in the footer
* The current release version
*/
public const VERSION = '2.0 dev';
public const VERSION = 0.6;
/**
* The release name, will only change for new major version
*/
public const NAME = 'Ayumi';
/**
* The current release status, either "stable" or "dev"
*/
public const STATUS = 'stable';
/**
* Used in multiple views
*/
public const GITHUB = 'https://github.com/kevinpapst/kimai2/';
/**
* Used in multiple views
*/
public const HOMEPAGE = 'https://v2.kimai.org';
}

View File

@@ -10,7 +10,7 @@
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Translation\DataCollectorTranslator;
/**
* The abstract base controller.
@@ -27,29 +27,13 @@ abstract class AbstractController extends Controller
public const ROLE_ADMIN = 'ROLE_ADMIN';
/**
* @return object|\Symfony\Component\Translation\DataCollectorTranslator|\Symfony\Component\Translation\IdentityTranslator
* @return DataCollectorTranslator
*/
protected function getTranslator()
private function getTranslator()
{
return $this->container->get('translator');
}
/**
* A translated helper for denyAccessUnlessGranted()
*
* @param mixed $attributes
* @param mixed $subject
* @param string $translation
* @param array $parameter
* @throws AccessDeniedException
*/
protected function denyUnlessGranted($attributes, $subject = null, $translation = 'access.denied', $parameter = [])
{
$error = $this->getTranslator()->trans($translation, $parameter, self::DOMAIN_ERROR);
// TODO try & catch and add to audit log?
$this->denyAccessUnlessGranted($attributes, $subject, $error);
}
/**
* Adds a "successful" flash message to the stack.
*
@@ -72,7 +56,7 @@ abstract class AbstractController extends Controller
/**
* Adds a "warning" flash message to the stack.
*
* @param $translationKey
* @param string $translationKey
* @param array $parameter
*/
protected function flashWarning($translationKey, $parameter = [])
@@ -91,7 +75,7 @@ abstract class AbstractController extends Controller
/**
* Adds a "error" flash message to the stack.
*
* @param $translationKey
* @param string $translationKey
* @param array $parameter
*/
protected function flashError($translationKey, $parameter = [])

View File

@@ -12,14 +12,14 @@ namespace App\Controller;
use App\Entity\Activity;
use App\Repository\ActivityRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;
/**
* Controller used to manage activity contents in the public part of the site.
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends Controller
class ActivityController extends AbstractController
{
/**
* @return ActivityRepository
@@ -32,17 +32,16 @@ class ActivityController extends Controller
/**
* The flyout to render recent activities and quick-start new recordings.
*
* @return \Symfony\Component\HttpFoundation\Response
* @return Response
*/
public function recentActivitiesAction()
{
$user = $this->getUser();
// TODO make days configurable
$activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
$entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days'));
return $this->render(
'navbar/recent-activities.html.twig',
['activities' => $activeEntries]
['entries' => $entries]
);
}
}

View File

@@ -0,0 +1,148 @@
<?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\Admin;
use App\Constants;
use App\Controller\AbstractController;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/about")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
*/
class AboutController extends AbstractController
{
/**
* @var string
*/
protected $projectDirectory;
/**
* @param string $projectDirectory
*/
public function __construct(string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="", name="about", methods={"GET"})
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction(Request $request)
{
$phpInfo = $this->getPhpInfo();
unset($phpInfo[0]);
unset($phpInfo[1]);
$ini = [
'allow_url_fopen',
'allow_url_include',
'default_charset',
'default_mimetype',
'display_errors',
'error_log',
'error_reporting',
'log_errors',
'max_execution_time',
'memory_limit',
'open_basedir',
'post_max_size',
'sys_temp_dir',
];
$settings = [];
foreach ($ini as $name) {
try {
$settings[$name] = ini_get($name);
} catch (\Exception $ex) {
$settings[$name] = "Couldn't load ini setting: " . $ex->getMessage();
}
}
return $this->render('admin/system.html.twig', [
'modules' => get_loaded_extensions(),
'dotenv' => [
'APP_ENV' => getenv('APP_ENV'),
'DATABASE_PREFIX' => getenv('DATABASE_PREFIX'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'MAILER_URL' => getenv('MAILER_URL'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
],
'info' => $phpInfo,
'settings' => $settings,
'license' => $this->getLicense(),
]);
}
/**
* @return string
*/
protected function getLicense()
{
$filename = $this->projectDirectory . '/LICENSE';
try {
$license = file_get_contents($filename);
} catch (\Exception $ex) {
$license = false;
}
if (false === $license) {
$license = 'Failed reading license file: ' . $filename . '. ' .
'Check this instead: ' . Constants::GITHUB . 'blob/master/LICENSE';
}
return $license;
}
/**
* @author https://php.net/manual/en/function.phpinfo.php#117961
* @return array
*/
private function getPhpInfo()
{
$plainText = function ($input) {
return trim(html_entity_decode(strip_tags($input)));
};
ob_start();
phpinfo(1);
$phpinfo = ['phpinfo' => []];
if (preg_match_all(
'#(?:<h2.*?>(?:<a.*?>)?(.*?)(?:<\/a>)?<\/h2>)|' .
'(?:<tr.*?><t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>(?:<t[hd].*?>(.*?)\s*</t[hd]>)?)?</tr>)#s',
ob_get_clean(),
$matches,
PREG_SET_ORDER
)) {
foreach ($matches as $match) {
$fn = $plainText;
if (isset($match[3])) {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][$fn($match[2])] = isset($match[4]) ? [$fn($match[3]), $fn($match[4])] : $fn($match[3]);
} else {
$keys1 = array_keys($phpinfo);
$phpinfo[end($keys1)][] = $fn($match[2]);
}
}
}
return $phpinfo['phpinfo'];
}
}

View File

@@ -13,7 +13,10 @@ use App\Controller\AbstractController;
use App\Entity\Activity;
use App\Form\ActivityEditForm;
use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -61,6 +64,7 @@ class ActivityController extends AbstractController
return $this->render('admin/activity.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -104,6 +108,22 @@ class ActivityController extends AbstractController
$stats = $this->getRepository()->getActivityStatistics($activity);
$deleteForm = $this->createFormBuilder()
->add('activity', ActivityType::class, [
'label' => 'label.activity',
'query_builder' => function (ActivityRepository $repo) use ($activity) {
$query = new ActivityQuery();
$query
->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER)
->setProject($activity->getProject())
->setOrderGlobalsFirst(true)
->addIgnoredEntity($activity)
->setGlobalsOnly(null === $activity->getProject())
;
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()]))
->setMethod('POST')
->getForm();
@@ -111,11 +131,12 @@ class ActivityController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($activity);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
try {
$this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_activity');
}
@@ -146,7 +167,7 @@ class ActivityController extends AbstractController
$entityManager->persist($activity);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newActivity = new Activity();

View File

@@ -13,7 +13,10 @@ use App\Controller\AbstractController;
use App\Entity\Customer;
use App\Form\CustomerEditForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
use App\Repository\Query\CustomerQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
@@ -28,6 +31,19 @@ use Symfony\Component\Routing\Annotation\Route;
*/
class CustomerController extends AbstractController
{
/**
* @var array
*/
private $defaults;
/**
* @param array $defaults
*/
public function __construct(array $defaults)
{
$this->defaults = $defaults;
}
/**
* @return \App\Repository\CustomerRepository
*/
@@ -58,6 +74,7 @@ class CustomerController extends AbstractController
return $this->render('admin/customer.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -67,7 +84,12 @@ class CustomerController extends AbstractController
*/
public function createAction(Request $request)
{
return $this->renderCustomerForm(new Customer(), $request);
$customer = new Customer();
$customer->setCountry($this->defaults['customer']['country']);
$customer->setCurrency($this->defaults['customer']['currency']);
$customer->setTimezone($this->defaults['customer']['timezone']);
return $this->renderCustomerForm($customer, $request);
}
/**
@@ -95,7 +117,7 @@ class CustomerController extends AbstractController
$entityManager->persist($customer);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
}
@@ -121,6 +143,18 @@ class CustomerController extends AbstractController
$stats = $this->getRepository()->getCustomerStatistics($customer);
$deleteForm = $this->createFormBuilder()
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
$query = new CustomerQuery();
$query
->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER)
->addIgnoredEntity($customer);
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))
->setMethod('POST')
->getForm();
@@ -128,11 +162,12 @@ class CustomerController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($customer);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
try {
$this->getRepository()->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_customer');
}

View File

@@ -14,7 +14,10 @@ use App\Entity\Customer;
use App\Entity\Project;
use App\Form\ProjectEditForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ProjectRepository;
use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -62,6 +65,7 @@ class ProjectController extends AbstractController
return $this->render('admin/project.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -98,6 +102,19 @@ class ProjectController extends AbstractController
$stats = $this->getRepository()->getProjectStatistics($project);
$deleteForm = $this->createFormBuilder()
->add('project', ProjectType::class, [
'label' => 'label.project',
'query_builder' => function (ProjectRepository $repo) use ($project) {
$query = new ProjectQuery();
$query
->setResultType(ProjectQuery::RESULT_TYPE_QUERYBUILDER)
->setCustomer($project->getCustomer())
->addIgnoredEntity($project);
return $repo->findByQuery($query);
},
'required' => false,
])
->setAction($this->generateUrl('admin_project_delete', ['id' => $project->getId()]))
->setMethod('POST')
->getForm();
@@ -105,11 +122,12 @@ class ProjectController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($project);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
try {
$this->getRepository()->deleteProject($project, $deleteForm->get('project')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
return $this->redirectToRoute('admin_project');
}
@@ -137,7 +155,7 @@ class ProjectController extends AbstractController
$entityManager->persist($project);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
$newProject = new Project();

View File

@@ -60,6 +60,12 @@ class TimesheetController extends AbstractController
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);
}
}
/* @var $entries Pagerfanta */
@@ -69,6 +75,7 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -118,7 +125,7 @@ class TimesheetController extends AbstractController
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", name="admin_timesheet_delete", methods={"GET", "POST"})
* @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry
@@ -132,9 +139,9 @@ class TimesheetController extends AbstractController
$entityManager->remove($entry);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.deleted.error', ['%reason%' => $ex->getMessage()]);
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('admin_timesheet_paginated', ['page' => $request->get('page')]);

View File

@@ -77,6 +77,7 @@ class UserController extends AbstractController
return $this->render('admin/user.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
@@ -102,7 +103,7 @@ class UserController extends AbstractController
$entityManager->persist($user);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
if ($editForm->get('create_more')->getData() !== true) {
return $this->redirectToRoute('user_profile_edit', ['username' => $user->getUsername()]);
@@ -149,7 +150,7 @@ class UserController extends AbstractController
$entityManager->remove($userToDelete);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
$this->flashSuccess('action.delete.success');
return $this->redirectToRoute('admin_user');
}

View File

@@ -13,7 +13,6 @@ use App\Event\DashboardEvent;
use App\Model\DashboardSection;
use App\Repository\WidgetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Routing\Annotation\Route;
@@ -23,7 +22,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/dashboard")
* @Security("is_granted('ROLE_USER')")
*/
class DashboardController extends Controller
class DashboardController extends AbstractController
{
/**
* @var EventDispatcherInterface

View File

@@ -11,7 +11,6 @@ namespace App\Controller;
use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/help")
* @Security("is_granted('ROLE_USER')")
*/
class HelpController extends Controller
class HelpController extends AbstractController
{
public const README = 'README';
public const DOCS_DIR = 'var/docs/';

View File

@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Entity\User;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Homepage controller is a redirect controller with user specific logic.
*
* @Route(path="/homepage")
* @Security("is_granted('ROLE_USER')")
*/
class HomepageController extends AbstractController
{
/**
* @Route(path="", defaults={}, name="homepage", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse
*/
public function indexAction(Request $request)
{
// make me configurable via UserPreference
$route = 'timesheet';
/** @var User $user */
$user = $this->getUser();
$locale = $request->getLocale();
$language = $user->getPreferenceValue('language', $locale);
return $this->redirectToRoute($route, ['_locale' => $language]);
}
}

View File

@@ -15,6 +15,7 @@ use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm;
use App\Invoice\ServiceInvoice;
use App\Model\InvoiceModel;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
@@ -35,13 +36,25 @@ class InvoiceController extends AbstractController
* @var ServiceInvoice
*/
protected $service;
/**
* @var InvoiceTemplateRepository
*/
protected $invoiceRepository;
/**
* @var TimesheetRepository
*/
protected $timesheetRepository;
/**
* @param ServiceInvoice $service
* @param InvoiceTemplateRepository $invoice
* @param TimesheetRepository $timesheet
*/
public function __construct(ServiceInvoice $service)
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, TimesheetRepository $timesheet)
{
$this->service = $service;
$this->invoiceRepository = $invoice;
$this->timesheetRepository = $timesheet;
}
/**
@@ -62,16 +75,9 @@ class InvoiceController extends AbstractController
return $query;
}
/**
* @return \App\Repository\InvoiceTemplateRepository
*/
protected function getRepository()
{
return $this->getDoctrine()->getRepository(InvoiceTemplate::class);
}
/**
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view', 'invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -79,7 +85,7 @@ class InvoiceController extends AbstractController
*/
public function indexAction(Request $request)
{
if (!$this->getRepository()->hasTemplate()) {
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
@@ -87,31 +93,103 @@ class InvoiceController extends AbstractController
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var InvoiceQuery $query */
$query = $form->getData();
$query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER);
$entries = $this->getEntries($query);
}
if (null !== $query->getCustomer()) {
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$model = $this->prepareModel($query, $entries);
/* @var TimesheetRepository $timeRepo */
$timeRepo = $this->getDoctrine()->getRepository(Timesheet::class);
$queryBuilder = $timeRepo->findByQuery($query);
$entries = $queryBuilder->getQuery()->getResult();
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
]);
}
/**
* @Route(path="/print", name="invoice_print", methods={"GET", "POST"})
* @Security("is_granted('create', 'invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function printAction(Request $request)
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->redirectToRoute('invoice');
}
/** @var InvoiceQuery $query */
$query = $form->getData();
$entries = $this->getEntries($query);
$model = $this->prepareModel($query, $entries);
$document = $this->service->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
return $renderer->render($document, $model);
}
}
$model = new InvoiceModel();
$model->setQuery($query);
$model->setEntries($entries);
$model->setCustomer($query->getCustomer());
$this->flashError('Cannot render invoice: ' . $model->getTemplate()->getRenderer() . ' (' . $document->getName() . ')');
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
]);
}
/**
* @param InvoiceQuery $query
* @return Timesheet[]
*/
protected function getEntries(InvoiceQuery $query)
{
// customer needs to be defined, as we need the currency for the invoice
if (null === $query->getCustomer()) {
return [];
}
$query->setResultType(TimesheetQuery::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 InvoiceQuery $query
* @param array $entries
* @return InvoiceModel
* @throws \Exception
*/
protected function prepareModel(InvoiceQuery $query, array $entries)
{
$model = new InvoiceModel();
$model
->setQuery($query)
->setEntries($entries)
->setCustomer($query->getCustomer())
;
$action = null;
if ($query->getTemplate() !== null) {
$generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
@@ -126,28 +204,22 @@ class InvoiceController extends AbstractController
$model->setTemplate($query->getTemplate());
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
$action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer());
}
return $this->render('invoice/index.html.twig', [
'model' => $model,
'action' => $action,
'form' => $form->createView(),
]);
return $model;
}
/**
* @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
* @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"})
*
* TODO permission
* @Security("is_granted('view', 'invoice_template')")
*
* @param $page
* @return \Symfony\Component\HttpFoundation\Response
*/
public function listTemplateAction($page)
{
$templates = $this->getRepository()->findByQuery(new BaseQuery());
$templates = $this->invoiceRepository->findByQuery(new BaseQuery());
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
@@ -156,10 +228,10 @@ class InvoiceController extends AbstractController
}
/**
* @Route(path="/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
*
* TODO permission
* @Route(path="/template/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
@@ -170,21 +242,60 @@ class InvoiceController extends AbstractController
}
/**
* @Route(path="/create", name="admin_invoice_template_create", methods={"GET", "POST"})
*
* TODO permission
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
* @Security("is_granted('create', 'invoice_template')")
*
* @param Request $request
* @param InvoiceTemplate|null $template
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function createTemplateAction(Request $request)
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom)
{
if (!$this->getRepository()->hasTemplate()) {
if (!$this->invoiceRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
return $this->renderTemplateForm(new InvoiceTemplate(), $request);
$template = new InvoiceTemplate();
if (null !== $copyFrom) {
$template
->setName('Copy of ' . $copyFrom->getName())
->setTitle($copyFrom->getTitle())
->setDueDays($copyFrom->getDueDays())
->setCalculator($copyFrom->getCalculator())
->setVat($copyFrom->getVat())
->setRenderer($copyFrom->getRenderer())
->setCompany($copyFrom->getCompany())
->setPaymentTerms($copyFrom->getPaymentTerms())
->setAddress($copyFrom->getAddress())
->setNumberGenerator($copyFrom->getNumberGenerator())
;
}
return $this->renderTemplateForm($template, $request);
}
/**
* The route to delete an existing template.
*
* @Route(path="/template/{id}/delete", name="admin_invoice_template_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function deleteTemplate(InvoiceTemplate $template, Request $request)
{
try {
$this->invoiceRepository->removeTemplate($template);
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('admin_invoice_template_paginated', ['page' => $request->get('page')]);
}
/**
@@ -199,13 +310,14 @@ class InvoiceController extends AbstractController
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($template);
$entityManager->flush();
try {
$this->invoiceRepository->saveTemplate($template);
$this->flashSuccess('action.update.success');
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute('admin_invoice_template');
return $this->redirectToRoute('admin_invoice_template');
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('invoice/template_edit.html.twig', [
@@ -214,28 +326,6 @@ class InvoiceController extends AbstractController
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function invoiceAction(InvoiceModel $model)
{
return $this->render('invoice/renderer/print.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function timesheetAction(InvoiceModel $model)
{
return $this->render('invoice/renderer/timesheet.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceQuery $query
* @return \Symfony\Component\Form\FormInterface
@@ -244,7 +334,10 @@ class InvoiceController extends AbstractController
{
return $this->createForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []),
'method' => 'GET',
'method' => 'POST',
'attr' => [
'id' => 'invoice-print-form'
]
]);
}

View File

@@ -45,6 +45,14 @@ class ProfileController extends AbstractController
$this->encoder = $encoder;
}
/**
* @Route(path="/", name="fos_user_profile_show", methods={"GET"})
*/
public function profileAction()
{
return $this->redirectToRoute('user_profile', ['username' => $this->getUser()->getUsername()]);
}
/**
* @Route(path="/{username}", name="user_profile", methods={"GET"})
* @Security("is_granted('view', profile)")
@@ -68,9 +76,9 @@ class ProfileController extends AbstractController
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_edit', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'settings', $form);
@@ -93,9 +101,9 @@ class ProfileController extends AbstractController
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'password', null, $form);
@@ -118,9 +126,9 @@ class ProfileController extends AbstractController
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'api-token', null, null, null, null, $form);
@@ -140,9 +148,9 @@ class ProfileController extends AbstractController
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'roles', null, null, $form);
@@ -184,9 +192,15 @@ class ProfileController extends AbstractController
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
// switch locale if neccessary
$locale = $profile->getPreferenceValue('language', $request->getLocale());
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
'username' => $profile->getUsername()
]);
}
return $this->getProfileView($profile, 'preferences', null, null, null, $form);

View File

@@ -9,8 +9,8 @@
namespace App\Controller;
use App\Form\UserPreferencesForm;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
/**
* Sidebar controller
@@ -20,34 +20,14 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
class SidebarController extends AbstractController
{
/**
* @param Request $originalRequest
* @return \Symfony\Component\HttpFoundation\Response
*/
public function homeAction()
public function settingsAction(Request $originalRequest)
{
return $this->render('sidebar/home.html.twig', []);
}
/**
* @return \Symfony\Component\HttpFoundation\Response
*/
public function settingsAction()
{
$user = $this->getUser();
return $this->render('sidebar/settings.html.twig', [
'user' => $user,
'user' => $this->getUser(),
'originalRequest' => $originalRequest,
]);
/*
$form = $this->createForm(UserPreferencesForm::class, $user, [
'action' => $this->generateUrl('user_profile_preferences', ['username' => $user->getUsername()]),
'method' => 'POST',
]);
return $this->render('sidebar/settings.html.twig', [
'form' => $form->createView(),
]);
*/
}
}

View File

@@ -9,7 +9,6 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
@@ -19,6 +18,7 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Controller used to manage timesheets.
@@ -42,6 +42,10 @@ class TimesheetController extends AbstractController
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
* @Cache(smaxage="10")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -53,6 +57,12 @@ class TimesheetController extends AbstractController
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);
}
}
$query->setUser($this->getUser());
@@ -64,12 +74,47 @@ class TimesheetController extends AbstractController
'entries' => $entries,
'page' => $page,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* The "main button and flyout" for displaying (and stopping) active entries.
* @Route(path="/export", name="timesheet_export", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function exportAction(Request $request)
{
$query = new TimesheetQuery();
$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);
}
}
$query->setUser($this->getUser());
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('timesheet/export.html.twig', [
'entries' => $entries,
'query' => $query,
]);
}
/**
* The "main button and fly-out" for displaying (and stopping) active entries.
*
* @return \Symfony\Component\HttpFoundation\Response
*/
@@ -99,20 +144,36 @@ class TimesheetController extends AbstractController
}
/**
* The route to stop a running entry.
* The route to re-start a timesheet entry.
*
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @Security("is_granted('start', activity)")
* @Security("is_granted('start', timesheet)")
*
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function startAction(Activity $activity)
public function startAction(ValidatorInterface $validator, Timesheet $timesheet)
{
$user = $this->getUser();
try {
$this->getRepository()->startRecording($user, $activity);
$this->flashSuccess('timesheet.start.success');
$entry = new Timesheet();
$entry
->setBegin(new \DateTime())
->setUser($user)
->setActivity($timesheet->getActivity())
->setProject($timesheet->getProject())
;
$errors = $validator->validate($entry);
if (count($errors) > 0) {
$this->flashError('timesheet.start.error', ['%reason%' => $errors[0]->getPropertyPath() . ' = ' . $errors[0]->getMessage()]);
} else {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}
@@ -155,7 +216,7 @@ class TimesheetController extends AbstractController
/**
* The route to delete an existing entry.
*
* @Route(path="/{id}/delete", name="timesheet_delete", methods={"GET", "POST"})
* @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry
@@ -169,9 +230,9 @@ class TimesheetController extends AbstractController
$entityManager->remove($entry);
$entityManager->flush();
$this->flashSuccess('action.deleted_successfully');
$this->flashSuccess('action.delete.success');
} catch (\Exception $ex) {
$this->flashError('action.deleted.error', ['%reason%' => $ex->getMessage()]);
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('timesheet_paginated', ['page' => $request->get('page')]);

View File

@@ -92,13 +92,11 @@ trait TimesheetControllerTrait
$record->setEnd($end);
}
// TODO validate that end is not before begin
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($redirectRoute, ['page' => $request->get('page')]);
}
@@ -170,14 +168,12 @@ trait TimesheetControllerTrait
}
}
// TODO validate that end is not before begin
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute($redirectRoute);
}

View File

@@ -30,6 +30,8 @@ class CustomerFixtures extends Fixture
public const MAX_CUSTOMERS = 15;
public const MIN_BUDGET = 0;
public const MAX_BUDGET = 100000;
public const MIN_GLOBAL_ACTIVITIES = 5;
public const MAX_GLOBAL_ACTIVITIES = 50;
public const MIN_PROJECTS_PER_CUSTOMER = 10;
public const MAX_PROJECTS_PER_CUSTOMER = 50;
public const MIN_ACTIVITIES_PER_PROJECT = 0;
@@ -66,6 +68,16 @@ class CustomerFixtures extends Fixture
$manager->flush();
$manager->clear();
}
$amountGlobalActivities = rand(self::MIN_GLOBAL_ACTIVITIES, self::MAX_GLOBAL_ACTIVITIES);
for ($c = 1; $c <= $amountGlobalActivities; $c++) {
$visibleActivity = 0 != $a % 3;
$activity = $this->createActivity($faker, null, $visibleActivity);
$manager->persist($activity);
}
$manager->flush();
$manager->clear();
}
/**
@@ -113,11 +125,11 @@ class CustomerFixtures extends Fixture
/**
* @param Generator $faker
* @param Project $project
* @param Project|null $project
* @param bool $visible
* @return Activity
*/
private function createActivity(Generator $faker, Project $project, $visible)
private function createActivity(Generator $faker, ?Project $project, $visible)
{
$entry = new Activity();
$entry

View File

@@ -13,6 +13,7 @@ use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory;
use Faker\Generator;
/**
* Defines the sample data to load in the database when running the unit and
@@ -28,38 +29,92 @@ class InvoiceFixtures extends Fixture
*/
public function load(ObjectManager $manager)
{
$this->loadInvoiceTemplates($manager);
foreach ($this->getInvoiceConfigs() as $invoiceConfig) {
$template = new InvoiceTemplate();
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
$template
->setName($invoiceConfig[0])
->setTitle($invoiceConfig[1])
->setRenderer($invoiceConfig[2])
->setCalculator($invoiceConfig[3])
->setNumberGenerator($invoiceConfig[4])
->setCompany($invoiceConfig[5])
->setVat($invoiceConfig[6])
->setDueDays($invoiceConfig[7])
->setAddress($invoiceConfig[8])
->setPaymentTerms($invoiceConfig[9])
;
$manager->persist($template);
$manager->flush();
}
}
/**
* @param ObjectManager $manager
*/
private function loadInvoiceTemplates(ObjectManager $manager)
private function getInvoiceConfigs()
{
$faker = Factory::create();
$template = new InvoiceTemplate();
$template
->setName('Invoice')
->setTitle('Your company name')
->setCompany($faker->company)
->setVat(19)
->setDueDays(14)
->setPaymentTerms(
'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL .
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
)
->setAddress(
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL .
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
)
$paymentTerms =
'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL .
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
;
$manager->persist($template);
$manager->flush();
$address =
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL .
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
;
$paymentTerms_de =
'Bitte überweisen Sie den Gesamtbetrag innerhalb von 14 Tagen nach Erhalt der Rechnung auf das unten genannte Konto. Verwenden Sie bitte als Betreff Ihrer Überweisung die Rechnungsnummer.' .
PHP_EOL .
PHP_EOL .
'Ich bedanke mich für das entgegengebrachte Vertrauen. Gerne bin ich auch künftig für Sie da.' .
PHP_EOL .
PHP_EOL .
'Mit freundlichen Grüßen,' .
PHP_EOL .
'Max Müller'
;
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
return [
['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $address, $paymentTerms],
['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $this->generateAddress($faker), $paymentTerms_de],
['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $this->generateAddress($faker), ''],
['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $this->generateAddress($faker, true), $this->generatePaymentTerms($faker)],
['Export (CSV, user-group)', 'User-grouped', 'export', 'user', 'default', $faker->company, 7, 28, '', ''],
['Export (ODS)', 'Spreadsheet', 'open-spreadsheet', 'default', 'default', $faker->company, 19, 14, '', ''],
['Export (XLSX, user-group)', 'Spreadsheet', 'spreadsheet', 'user', 'default', $faker->company, 13, 10, '', ''],
];
}
protected function generatePaymentTerms(Generator $faker)
{
return
'Acme Bank' . PHP_EOL .
'Account: ' . $faker->bankAccountNumber . PHP_EOL .
'IBAN: ' . $faker->iban('DE')
;
}
protected function generateAddress(Generator $faker, $lineBreaks = false)
{
if (!$lineBreaks) {
return
$faker->name . ' - ' .
$faker->streetAddress . '-' .
$faker->postcode . ' ' . $faker->city;
}
return
'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode
;
}
}

View File

@@ -10,6 +10,7 @@
namespace App\DataFixtures;
use App\Entity\Activity;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
@@ -56,6 +57,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
{
$allUser = $this->getAllUsers($manager);
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);
$faker = Factory::create();
@@ -80,6 +82,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
$description
);
@@ -98,6 +101,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
null,
false
);
@@ -126,6 +130,22 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
*/
protected function getAllProjects(ObjectManager $manager)
{
$all = [];
/* @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param ObjectManager $manager
* @return Activity[]
@@ -133,7 +153,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
protected function getAllActivities(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
/* @var Activity[] $entries */
$entries = $manager->getRepository(Activity::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
@@ -142,7 +162,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
return $all;
}
private function createTimesheetEntry(User $user, Activity $activity, $description, $setEndDate = true)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, $setEndDate = true)
{
$start = new \DateTime();
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
@@ -151,6 +171,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$entry = new Timesheet();
$entry
->setActivity($activity)
->setProject($activity->getProject() ?? $project)
->setDescription($description)
->setUser($user)
->setBegin($start);

View File

@@ -10,7 +10,6 @@
namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Definition\Exception\InvalidDefinitionException;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
@@ -36,64 +35,57 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.languages', $config['languages']);
$container->setParameter('kimai.calendar', $config['calendar']);
$container->setParameter('kimai.theme', $config['theme']);
$container->setParameter('kimai.dashboard', $config['dashboard']);
$container->setParameter('kimai.widgets', $config['widgets']);
$container->setParameter('kimai.invoice.documents', $config['invoice']['documents']);
$container->setParameter('kimai.defaults', $config['defaults']);
$this->createUserParameter($config, $container);
$this->createTimesheetParameter($config, $container);
$this->createInvoiceParameter($config, $container);
$this->createThemeParameter($config['theme'], $container);
$this->createUserParameter($config['user'], $container);
$this->createTimesheetParameter($config['timesheet'], $container);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
public function createUserParameter(array $config, ContainerBuilder $container)
protected function createThemeParameter(array $config, ContainerBuilder $container)
{
if (!$config['user']['registration']) {
$container->setParameter('kimai.theme', $config);
$container->setParameter('kimai.theme.select_type', $config['select_type']);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
protected function createUserParameter(array $config, ContainerBuilder $container)
{
if (!$config['registration']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_registration'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
if (!$config['user']['password_reset']) {
if (!$config['password_reset']) {
$routes = $container->getParameter('admin_lte_theme.routes');
$routes['adminlte_password_reset'] = null;
$container->setParameter('admin_lte_theme.routes', $routes);
}
$container->setParameter('kimai.fosuser', $config['user']);
$container->setParameter('kimai.fosuser', $config);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
public function createTimesheetParameter(array $config, ContainerBuilder $container)
protected function createTimesheetParameter(array $config, ContainerBuilder $container)
{
$container->setParameter('kimai.timesheet.rates', $config['timesheet']['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['timesheet']['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['timesheet']['duration_only']);
}
/**
* @param array $config
* @param ContainerBuilder $container
*/
private function createInvoiceParameter(array $config, ContainerBuilder $container)
{
$keys = ['renderer', 'calculator', 'number_generator'];
foreach ($keys as $key) {
if (!isset($config['invoice'][$key]) || 0 === count($config['invoice'][$key])) {
throw new InvalidDefinitionException('Missing invoice configuration: kimai.invoice.' . $key);
}
$container->setParameter('kimai.invoice.' . $key, $config['invoice'][$key]);
}
$container->setParameter('kimai.invoice', $config['invoice']);
$container->setParameter('kimai.timesheet.rates', $config['rates']);
$container->setParameter('kimai.timesheet.rounding', $config['rounding']);
$container->setParameter('kimai.timesheet.duration_only', $config['duration_only']);
$container->setParameter('kimai.timesheet.markdown', $config['markdown_content']);
}
/**

View File

@@ -0,0 +1,51 @@
<?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\Invoice\ServiceInvoice;
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 InvoiceService.
*/
class InvoiceServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceInvoice::class)) {
return;
}
$definition = $container->findDefinition(ServiceInvoice::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
$taggedGenerator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_NUMBER_GENERATOR);
foreach ($taggedGenerator as $id => $tags) {
$definition->addMethodCall('addNumberGenerator', [new Reference($id)]);
}
$taggedCalculator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_CALCULATOR);
foreach ($taggedCalculator as $id => $tags) {
$definition->addMethodCall('addCalculator', [new Reference($id)]);
}
}
}

View File

@@ -29,5 +29,17 @@ class TwigContextCompilerPass implements CompilerPassInterface
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
$twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]);
if ($container->hasDefinition('twig.loader.native_filesystem')) {
$definition = $container->getDefinition('twig.loader.native_filesystem');
$path = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR;
foreach ($container->getParameter('kimai.invoice.documents') as $invoicePath) {
if (!is_dir($path . $invoicePath)) {
continue;
}
$definition->addMethodCall('addPath', [$path . $invoicePath, 'invoice']);
}
}
}
}

View File

@@ -39,6 +39,7 @@ class Configuration implements ConfigurationInterface
->append($this->getThemeNode())
->append($this->getDashboardNode())
->append($this->getWidgetsNode())
->append($this->getDefaultsNode())
->end()
->end();
@@ -55,6 +56,9 @@ class Configuration implements ConfigurationInterface
->booleanNode('duration_only')
->defaultValue(false)
->end()
->booleanNode('markdown_content')
->defaultValue(false)
->end()
->arrayNode('rounding')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
@@ -96,6 +100,12 @@ class Configuration implements ConfigurationInterface
->floatNode('factor')
->isRequired()
->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('A rate factor smaller or equals 0 is not allowed')
->end()
->end()
->end()
->end()
@@ -113,32 +123,15 @@ class Configuration implements ConfigurationInterface
$node = $builder->root('invoice');
$node
->addDefaultsIfNotSet()
->children()
->arrayNode('renderer')
->arrayNode('documents')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->scalarPrototype()->end()
->defaultValue([
'default' => 'App\Controller\InvoiceController::invoiceAction',
])
->end()
->arrayNode('calculator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DefaultCalculator',
])
->end()
->arrayNode('number_generator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DateNumberGenerator',
'var/invoices/',
'templates/invoice/renderer/'
])
->end()
->end()
@@ -222,6 +215,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('box_color')
->defaultValue('green')
->end()
->scalarNode('select_type')
->defaultNull()
->end()
->end()
;
@@ -311,4 +307,26 @@ class Configuration implements ConfigurationInterface
return $node;
}
protected function getDefaultsNode()
{
$builder = new TreeBuilder();
$node = $builder->root('defaults');
$node
->addDefaultsIfNotSet()
->children()
->arrayNode('customer')
->addDefaultsIfNotSet()
->children()
->scalarNode('timezone')->defaultValue('Europe/Berlin')->end()
->scalarNode('country')->defaultValue('DE')->end()
->scalarNode('currency')->defaultValue('EUR')->end()
->end()
->end()
->end()
;
return $node;
}
}

View File

@@ -9,6 +9,8 @@
namespace App\Doctrine;
use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use Doctrine\DBAL\DBALException;
use Doctrine\Migrations\AbstractMigration as BaseAbstractMigration;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -50,7 +52,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
/**
* @return string
* @throws \Doctrine\DBAL\DBALException
* @throws DBALException
*/
protected function getPlatform()
{
@@ -62,7 +64,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
* $schema = $this->getClassMetaData(User::class);
*
* @param string $entityName
* @return \Doctrine\Common\Persistence\Mapping\ClassMetadata
* @return ClassMetadata
*/
protected function getClassMetaData($entityName)
{
@@ -77,7 +79,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
*
* @param string $indexName
* @param string $tableName
* @throws \Doctrine\DBAL\DBALException
* @throws DBALException
*/
protected function addSqlDropIndex($indexName, $tableName)
{

View File

@@ -34,7 +34,6 @@ class Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="activities")
* @ORM\JoinColumn(onDelete="CASCADE")
* @Assert\NotNull()
*/
private $project;
@@ -69,6 +68,30 @@ class Activity
*/
private $timesheets;
/**
* @var float
*
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/**
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return Timesheet[]
*/
@@ -97,8 +120,6 @@ class Activity
}
/**
* Set name
*
* @param string $name
* @return Activity
*/
@@ -110,8 +131,6 @@ class Activity
}
/**
* Get name
*
* @return string
*/
public function getName()
@@ -120,8 +139,6 @@ class Activity
}
/**
* Set comment
*
* @param string $comment
* @return Activity
*/
@@ -133,8 +150,6 @@ class Activity
}
/**
* Get comment
*
* @return string
*/
public function getComment()
@@ -143,10 +158,7 @@ class Activity
}
/**
* Set visible
*
* @param bool $visible
*
* @return Activity
*/
public function setVisible($visible)
@@ -157,8 +169,6 @@ class Activity
}
/**
* Get visible
*
* @return bool
*/
public function getVisible()
@@ -167,13 +177,41 @@ class Activity
}
/**
* Get activity id
*
* @return int
* @return float
*/
public function getId()
public function getFixedRate(): ?float
{
return $this->id;
return $this->fixedRate;
}
/**
* @param float $fixedRate
* @return Activity
*/
public function setFixedRate(?float $fixedRate)
{
$this->fixedRate = $fixedRate;
return $this;
}
/**
* @return float
*/
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
/**
* @param float $hourlyRate
* @return Activity
*/
public function setHourlyRate(?float $hourlyRate)
{
$this->hourlyRate = $hourlyRate;
return $this;
}
/**

View File

@@ -149,6 +149,22 @@ class Customer
*/
private $timezone;
/**
* @var float
*
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/**
* @return int
*/
@@ -509,6 +525,44 @@ class Customer
return $this->projects;
}
/**
* @return float
*/
public function getFixedRate(): ?float
{
return $this->fixedRate;
}
/**
* @param float $fixedRate
* @return Customer
*/
public function setFixedRate(?float $fixedRate)
{
$this->fixedRate = $fixedRate;
return $this;
}
/**
* @return float
*/
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
/**
* @param float $hourlyRate
* @return Customer
*/
public function setHourlyRate(?float $hourlyRate)
{
$this->hourlyRate = $hourlyRate;
return $this;
}
/**
* @return string
*/

View File

@@ -0,0 +1,52 @@
<?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\Entity;
class InvoiceDocument
{
/**
* @var \SplFileInfo
*/
private $file;
/**
* @param \SplFileInfo $file
*/
public function __construct(\SplFileInfo $file)
{
$this->file = $file;
}
/**
* @return string
*/
public function getId()
{
$file = $this->file->getFilename();
return substr($file, 0, strpos($file, '.'));
}
/**
* @return string
*/
public function getName()
{
return basename($this->getFilename());
}
/**
* @return string
*/
public function getFilename(): string
{
return $this->file->getRealPath();
}
}

View File

@@ -37,8 +37,9 @@ class InvoiceTemplate
/**
* @var string
*
* @ORM\Column(name="name", type="string", length=255, nullable=false)
* @ORM\Column(name="name", type="string", length=60, nullable=false)
* @Assert\NotBlank()
* @Assert\Length(min=1, max=60)
*/
private $name;
@@ -76,8 +77,8 @@ class InvoiceTemplate
/**
* @var float
*
* @ORM\Column(name="vat", type="integer", length=2, nullable=true)
* @Assert\Range(min = 0, max = 99)
* @ORM\Column(name="vat", type="float", nullable=false)
* @Assert\Range(min = 0.0, max = 99.99)
*/
private $vat = 0.00;

View File

@@ -33,7 +33,7 @@ class Project
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer", inversedBy="projects")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
@@ -86,8 +86,29 @@ class Project
private $activities;
/**
* Get projectid
* @var float
*
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/**
* @var Timesheet[]
*
* @ORM\OneToMany(targetEntity="App\Entity\Timesheet", mappedBy="project")
*/
private $timesheets;
/**
* @return int
*/
public function getId()
@@ -104,8 +125,8 @@ class Project
}
/**
* @param $customer
* @return $this
* @param Customer $customer
* @return Project
*/
public function setCustomer($customer)
{
@@ -115,8 +136,6 @@ class Project
}
/**
* Set name
*
* @param string $name
* @return Project
*/
@@ -128,8 +147,6 @@ class Project
}
/**
* Get name
*
* @return string
*/
public function getName()
@@ -138,8 +155,6 @@ class Project
}
/**
* Set comment
*
* @param string $comment
* @return Project
*/
@@ -151,8 +166,6 @@ class Project
}
/**
* Get comment
*
* @return string
*/
public function getComment()
@@ -161,8 +174,6 @@ class Project
}
/**
* Set visible
*
* @param bool $visible
* @return Project
*/
@@ -174,8 +185,6 @@ class Project
}
/**
* Get visible
*
* @return bool
*/
public function getVisible()
@@ -184,8 +193,6 @@ class Project
}
/**
* Set budget
*
* @param float $budget
* @return Project
*/
@@ -197,8 +204,6 @@ class Project
}
/**
* Get budget
*
* @return float
*/
public function getBudget()
@@ -206,6 +211,25 @@ class Project
return $this->budget;
}
/**
* @param Timesheet[] $timesheets
* @return Project
*/
public function setTimesheets($timesheets)
{
$this->timesheets = $timesheets;
return $this;
}
/**
* @return Timesheet[]
*/
public function getTimesheets()
{
return $this->timesheets;
}
/**
* @param Activity[] $activities
* @return Project
@@ -226,7 +250,7 @@ class Project
}
/**
* @return string
* @return string|null
*/
public function getOrderNumber(): ?string
{
@@ -244,6 +268,44 @@ class Project
return $this;
}
/**
* @return float
*/
public function getFixedRate(): ?float
{
return $this->fixedRate;
}
/**
* @param float $fixedRate
* @return Project
*/
public function setFixedRate(?float $fixedRate)
{
$this->fixedRate = $fixedRate;
return $this;
}
/**
* @return float
*/
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
/**
* @param float $hourlyRate
* @return Project
*/
public function setHourlyRate(?float $hourlyRate)
{
$this->hourlyRate = $hourlyRate;
return $this;
}
/**
* @return string
*/

View File

@@ -64,7 +64,7 @@ class Timesheet
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE")
* @ORM\JoinColumn(name="user", referencedColumnName="id", onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $user;
@@ -73,11 +73,20 @@ class Timesheet
* @var Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Activity", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $activity;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project", inversedBy="timesheets")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $project;
/**
* @var string
*
@@ -89,9 +98,26 @@ class Timesheet
* @var float
*
* @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false)
* @Assert\GreaterThanOrEqual(0)
*/
private $rate = 0.00;
/**
* @var float
*
* @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/**
* Get entry id
*
@@ -136,6 +162,8 @@ class Timesheet
public function setEnd($end)
{
$this->end = $end;
// FIXME test and then remove it, this should not be neccessary
if (null === $end) {
$this->duration = 0;
}
@@ -213,6 +241,25 @@ class Timesheet
return $this->activity;
}
/**
* @return Project
*/
public function getProject()
{
return $this->project;
}
/**
* @param Project $project
* @return Timesheet
*/
public function setProject(Project $project)
{
$this->project = $project;
return $this;
}
/**
* Set description
*
@@ -260,6 +307,46 @@ class Timesheet
}
/**
* @return float
*/
public function getFixedRate(): ?float
{
return $this->fixedRate;
}
/**
* @param float $fixedRate
* @return Timesheet
*/
public function setFixedRate(?float $fixedRate)
{
$this->fixedRate = $fixedRate;
return $this;
}
/**
* @return float
*/
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
/**
* @param float $hourlyRate
* @return Timesheet
*/
public function setHourlyRate(?float $hourlyRate)
{
$this->hourlyRate = $hourlyRate;
return $this;
}
/**
* These validations are used in places, where we don't use a form yet (like the API).
*
* @param ExecutionContextInterface $context
* @param mixed $payload
*
@@ -267,7 +354,58 @@ class Timesheet
*/
public function validate(ExecutionContextInterface $context, $payload)
{
if (null !== $this->getEnd() && $this->getEnd()->getTimestamp() < $this->getBegin()->getTimestamp()) {
if (null === ($activity = $this->getActivity())) {
$context->buildViolation('A timesheet must have an activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if (null === ($project = $this->getProject())) {
$context->buildViolation('A timesheet must have a project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
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();
}
if ($activity->getVisible() === false) {
$context->buildViolation('Cannot start a disabled activity.')
->atPath('activity')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getVisible() === false) {
$context->buildViolation('Cannot start a disabled project.')
->atPath('project')
->setTranslationDomain('validators')
->addViolation();
}
if ($project->getCustomer()->getVisible() === false) {
$context->buildViolation('Cannot start a disabled customer.')
->atPath('customer')
->setTranslationDomain('validators')
->addViolation();
}
}
if (null === $this->getBegin() && null !== $this->getEnd()) {
$context->buildViolation('You must submit a begin date before an end date is added.')
->atPath('begin')
->setTranslationDomain('validators')
->addViolation();
}
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')

View File

@@ -89,10 +89,6 @@ class MenuBuilderSubscriber implements EventSubscriberInterface
);
}
$event->addItem(
new MenuItemModel('logout', 'menu.logout', 'fos_user_security_logout', [], 'fas fa-sign-out-alt')
);
$this->activateByRoute(
$event->getRequest()->get('_route'),
$event->getItems()

View File

@@ -9,8 +9,10 @@
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
@@ -21,7 +23,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
*
* @author Oleg Voronkovich <oleg-voronkovich@yandex.ru>
*/
class RedirectToLocaleSubscriber
class RedirectToLocaleSubscriber implements EventSubscriberInterface
{
/**
* @var UrlGeneratorInterface
@@ -70,6 +72,16 @@ class RedirectToLocaleSubscriber
$this->locales = array_unique($this->locales);
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest']
];
}
/**
* @param GetResponseEvent $event
*/

View File

@@ -18,7 +18,7 @@ use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Class ThemeOptionsSubscriber
* Allows dynamic injection of theme related options.
*/
class ThemeOptionsSubscriber implements EventSubscriberInterface
{
@@ -33,7 +33,6 @@ class ThemeOptionsSubscriber implements EventSubscriberInterface
protected $helper;
/**
* ThemeOptionsSubscriber constructor.
* @param TokenStorageInterface $storage
* @param ContextHelper $helper
*/

View File

@@ -12,11 +12,14 @@ namespace App\EventSubscriber;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\UserPreferenceEvent;
use App\Form\Type\CalendarViewType;
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\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
use Symfony\Component\HttpKernel\Event\KernelEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
@@ -69,6 +72,16 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(0)
->setType(IntegerType::class)
->addConstraint(new Range(['min' => 0])),
/*
(new UserPreference())
->setName('timezone')
->setValue(date_default_timezone_get())
->setType(TimezoneType::class),
*/
(new UserPreference())
->setName('language')
->setValue('en') // TODO fetch from services.yaml
->setType(LanguageType::class),
(new UserPreference())
->setName(UserPreference::SKIN)
@@ -95,12 +108,10 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
->setValue(true)
->setType(CheckboxType::class),
/*
(new UserPreference())
->setName('language')
->setValue('de')
->setType(LanguageType::class),
*/
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)
->setType(CalendarViewType::class),
];
}

View File

@@ -10,11 +10,14 @@
namespace App\Form;
use App\Entity\Activity;
use App\Form\Type\CustomerType;
use App\Form\Type\ProjectType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -34,8 +37,11 @@ class ActivityEditForm extends AbstractType
$entry = $options['data'];
$project = null;
if ($entry->getId() !== null) {
$customer = null;
if (null !== $entry->getProject()) {
$project = $entry->getProject();
$customer = $project->getCustomer();
}
$builder
@@ -48,13 +54,33 @@ class ActivityEditForm extends AbstractType
'label' => 'label.comment',
'required' => false,
])
// entity type: project
->add('customer', CustomerType::class, [
'label' => 'label.customer',
'query_builder' => function (CustomerRepository $repo) use ($customer) {
return $repo->builderForEntityType($customer);
},
'required' => false,
'mapped' => false,
'attr' => [
'data-related-select' => $this->getBlockPrefix() . '_project',
'data-api-url' => ['get_projects', ['customer' => '-s-']],
],
])
->add('project', ProjectType::class, [
'label' => 'label.project',
'required' => false,
'query_builder' => function (ProjectRepository $repo) use ($project) {
return $repo->builderForEntityType($project);
},
])
->add('fixedRate', NumberType::class, [
'label' => 'label.fixed_rate',
'required' => false,
])
->add('hourlyRate', NumberType::class, [
'label' => 'label.hourly_rate',
'required' => false,
])
// boolean
->add('visible', YesNoType::class, [
'label' => 'label.visible',

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\CurrencyType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -45,9 +46,6 @@ class CustomerEditForm extends AbstractType
'label' => 'label.comment',
'required' => false,
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',
])
->add('company', TextType::class, [
'label' => 'label.company',
'required' => false,
@@ -89,6 +87,17 @@ class CustomerEditForm extends AbstractType
->add('timezone', TimezoneType::class, [
'label' => 'label.timezone',
])
->add('fixedRate', NumberType::class, [
'label' => 'label.fixed_rate',
'required' => false,
])
->add('hourlyRate', NumberType::class, [
'label' => 'label.hourly_rate',
'required' => false,
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',
])
;
}

View File

@@ -0,0 +1,65 @@
<?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\Extension;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
/**
* Converts normal select boxes into javascript enhanced versions.
*/
class EnhancedChoiceTypeExtension extends AbstractTypeExtension
{
public const TYPE_SELECTPICKER = 'selectpicker';
/**
* @var string|null
*/
protected $type = null;
/**
* @param null|string $type
*/
public function __construct(?string $type)
{
$this->type = $type;
}
/**
* @return string
*/
public function getExtendedType()
{
return EntityType::class;
}
/**
* @param FormView $view
* @param FormInterface $form
* @param array $options
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
if ($this->type !== self::TYPE_SELECTPICKER) {
return;
}
if (!isset($view->vars['attr'])) {
$view->vars['attr'] = [];
}
$view->vars['attr'] = array_merge(
$view->vars['attr'],
['class' => 'selectpicker', 'data-live-search' => true, 'data-width' => '100%']
);
}
}

View File

@@ -14,7 +14,7 @@ use App\Form\Type\InvoiceCalculatorType;
use App\Form\Type\InvoiceNumberGeneratorType;
use App\Form\Type\InvoiceRendererType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\PercentType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -51,9 +51,9 @@ class InvoiceTemplateForm extends AbstractType
->add('dueDays', TextType::class, [
'label' => 'label.due_days',
])
->add('vat', PercentType::class, [
->add('vat', NumberType::class, [
'label' => 'label.vat',
'type' => 'integer',
'scale' => 2,
])
->add('renderer', InvoiceRendererType::class, [])
->add('calculator', InvoiceCalculatorType::class, [])

View File

@@ -17,6 +17,7 @@ use App\Repository\CustomerRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -58,14 +59,22 @@ class ProjectEditForm extends AbstractType
return $repo->builderForEntityType($customer);
},
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',
->add('fixedRate', NumberType::class, [
'label' => 'label.fixed_rate',
'required' => false,
])
->add('hourlyRate', NumberType::class, [
'label' => 'label.hourly_rate',
'required' => false,
])
->add('budget', MoneyType::class, [
'label' => 'label.budget',
'currency' => $customer ? $customer->getCurrency() : $builder->getOption('currency'),
'required' => false,
])
->add('visible', YesNoType::class, [
'label' => 'label.visible',
])
;
if ($entry->getId() === null) {

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