Compare commits

...

113 Commits
0.3 ... 0.7

Author SHA1 Message Date
Kevin Papst
67dab35b57 prepare release 0.7 (#514) 2019-01-28 01:40:42 +01:00
Kevin Papst
55d8bc9ba5 pagination without reload while keeping filters applied (#521) 2019-01-23 18:48:17 +01:00
Kevin Papst
572f01ed74 go back to calendar after editing and creation of time-records (#519) 2019-01-23 17:30:45 +01:00
Kevin Papst
ee32d6f926 fix wrong include filename in user registration (#520) 2019-01-23 14:16:37 +01:00
Kevin Papst
89abaa9dda fetch toolbar results without page reload (#518) 2019-01-23 02:45:33 +01:00
Kevin Papst
a9ece209ae Form and theme improvements (#513) 2019-01-21 13:10:53 +01:00
Lukas
47ac50b4d0 Only one running timesheet: automatically stop others (#386) 2019-01-21 12:49:22 +01:00
Kevin Papst
5539f68a54 validation for future and negative times (#512) 2019-01-21 01:51:57 +01:00
Kevin Papst
5b1ec1eb8f alphabetical order for selectboxes (#510) 2019-01-19 19:24:29 +01:00
Kevin Papst
a609cbf84a export team timesheets (#508) 2019-01-19 00:25:00 +01:00
Kevin Papst
1ad440f488 support template variable entry.row for docx if description is not used (#504) 2019-01-18 16:19:02 +01:00
Kevin Papst
64330e6cc4 display invoice entries in ascending order (#505) 2019-01-18 14:32:08 +01:00
Kevin Papst
03421c099a fix broken sql in activity admin (#506)
* fix broken sql in activity admin
* pre-select customer for existing activities
2019-01-18 14:30:20 +01:00
Lukas
b5c4084452 support anchor links for inline documentation (#482) 2019-01-17 23:30:02 +01:00
Kevin Papst
5d5b971f57 do not display admin menu if it has no children (#500) 2019-01-17 01:25:00 +01:00
Lukas
825d31af20 improve login screen and simplify customizations (#493) 2019-01-17 01:01:30 +01:00
Toby Batch
7880f980a8 Better composer install in docker (#481) 2018-12-21 14:32:04 +01:00
Steve Colson
137fd69f47 Update dockerfile for https in composer installer (#478) 2018-12-20 13:01:09 +01:00
Kevin Papst
ed8efcf139 support line breaks in docx (#466) 2018-12-07 11:24:39 +01:00
Kevin Papst
7fa3b67cc4 updated README and installation docu (#465) 2018-12-05 23:47:59 +01:00
Kevin Papst
93c500fbf2 fix segmentation fault - rollback composer dependencies (#463) 2018-12-05 00:01:48 +01:00
Kevin Papst
c58c12ea3d fixed null project for advanced invoice calculator (#462) 2018-12-03 22:36:38 +01:00
Kevin Papst
35305ad107 updated all composer packages (#459) 2018-12-03 20:24:22 +01:00
Kevin Papst
589d750e59 allow decimals in users hourly rate (#460) 2018-12-03 20:23:52 +01:00
Kevin Papst
43b488f21f moved some packages to dev requirements (#456) 2018-12-01 19:53:44 +01:00
Matheus
54d06462fc added portuguese translations (#446) 2018-11-28 18:22:05 +01:00
Kevin Papst
2d6109d587 simplify timesheet edit form if only one customer is existing (#443) 2018-11-28 15:05:12 +01:00
Kevin Papst
8fddf627bf added configurable permission system (#424) 2018-11-26 13:20:32 +01:00
Kevin Papst
0334f6ce86 added project variables for invoice templates (#439) 2018-11-23 15:56:06 +01:00
Kevin Papst
025558e58e fix restart timesheet button (#436) 2018-11-22 21:48:05 +01:00
Kevin Papst
f74547008d prepare release 0.6.1 (#433) 2018-11-19 16:17:28 +01:00
Kevin Papst
67dc1c7640 fix migration for MySQL (#430) 2018-11-19 15:24:00 +01:00
Kevin Papst
40322c6de4 fix importer for MySQL #428 (#429) 2018-11-19 00:32:12 +01:00
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
Kevin Papst
6fb6a16246 prepare release 0.4 (#292) 2018-09-01 21:05:29 +02:00
Kevin Papst
3af8070a57 fixed money display in dashboard widgets (#291) 2018-09-01 19:47:03 +02:00
Kevin Papst
ebe007eb78 added configurable formats for duration (#287) 2018-09-01 17:54:17 +02:00
Kevin Papst
55b7e192aa Symfony update 4.1.4 (#290) 2018-09-01 14:23:38 +02:00
Kevin Papst
62f91f4d38 do not allow to stop already stopped timesheets (#289) 2018-09-01 13:00:30 +02:00
Kevin Papst
b1546c0568 Updated russian translations (#286) 2018-09-01 11:20:54 +02:00
Thierry Pasquier
af2d1c3a6e fix french date format and update translations (#279) 2018-08-23 20:46:30 +02:00
Thierry Pasquier
ca893b303a Fix standard french date format 2018-08-19 10:15:24 +02:00
Kevin Papst
142369d651 activated spanish, french and italian 2018-08-18 23:39:40 +02:00
Thierry Pasquier
3241c73e7c Added French translations (#275) 2018-08-18 23:39:40 +02:00
Ivan Castro
d6d9abd05e added Spanish translations (#266)
Spanish translation for kimai
2018-08-18 23:39:40 +02:00
Kevin Papst
85ff2ff9de updated to AdminLTE 2.4.8 (#274) 2018-08-17 23:54:26 +02:00
Kevin Papst
dbbb434723 dashboard widgets are configurable via config (#269) 2018-08-17 23:17:02 +02:00
Ingo Ebel
7804ef83ce fixing migration_v1.md url (#270) 2018-08-13 10:15:34 +02:00
Kevin Papst
fb2d898e4c fixed calendar for empty timesheet descriptions (#265) 2018-08-10 08:19:30 +02:00
Kevin Papst
c6e99b35d5 moved twig globals to dynamic compiler pass (#264) 2018-08-10 00:11:48 +02:00
Kevin Papst
3dcd38eca7 Added config setting to disable password-reset and user-registration (#261) 2018-08-09 20:44:49 +02:00
Kevin Papst
39ea44fdc3 fixed .htaccess for apache users (#260) 2018-08-08 23:35:02 +02:00
Kevin Papst
2f84f3751a Added basic API endpoints (#258) 2018-08-08 23:10:45 +02:00
Kevin Papst
a7780dac9e added italian translation (#253) 2018-08-08 00:00:24 +02:00
Kevin Papst
e549a0824f Hotfix for Symfony Flex incompatibility with latest composer changes (#257) 2018-08-07 20:17:56 +02:00
Kevin Papst
8b8583d48b Merge branch 'translations' into master 2018-07-31 17:37:59 +02:00
Kevin Papst
a8a8424ced Added delete user feature #225 (#249) 2018-07-31 13:16:40 +02:00
Kevin Papst
0996eca3c5 dashboard widgets from event (#246) 2018-07-28 23:18:28 +02:00
Kevin Papst
e6f8bc8ae2 Added timesheet-calendar view (#236) 2018-07-27 20:19:56 +02:00
Kevin Papst
5ccc1c991b Improved data fixtures with Faker (#234) 2018-07-24 19:24:58 +02:00
Simon Schaufelberger
1dd5f2b72f Translations 2018-06-23 00:48:11 +02:00
478 changed files with 32383 additions and 5142 deletions

View File

@@ -14,6 +14,8 @@ lockComment: >
This thread has been automatically locked since there has not been
any recent activity after it was closed. Please open a new issue for
related bugs.
If you use Kimai on a daily basis, please [consider donating](https://www.kimai.org/donate/) to
support further development of Kimai.
# Assign `resolved` as the reason for locking. Set to `false` to disable
setLockReason: false

View File

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

21
.gitignore vendored
View File

@@ -1,4 +1,3 @@
# ========== KIMAI ==========
# IDE
.idea/
@@ -7,11 +6,17 @@
!bin/console
/config/packages/local.yaml
/var/data/kimai.sqlite
templates/invoice/renderer/.~lock*
/var/data/*.sqlite
/var/data/*.sqlite-journal
!/var/data/kimai_test.sqlite
/var/coverage/
/var/cache/*
!var/cache/.gitkeep
/var/invoices/*
/var/log/*
!var/log/.gitkeep
@@ -31,18 +36,20 @@
###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.result.cache
###< phpunit/phpunit ###
###> friendsofphp/php-cs-fixer ###
.php_cs
.php_cs.cache
###< friendsofphp/php-cs-fixer ###
###> symfony/webpack-encore-pack ###
/node_modules/
###< symfony/webpack-encore-pack ###
###> symfony/phpunit-bridge ###
.phpunit
/phpunit.xml
###< symfony/phpunit-bridge ###
###> symfony/webpack-encore-pack ###
/node_modules/
npm-debug.log
yarn-error.log
###< symfony/webpack-encore-pack ###

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,20 +1,277 @@
# Change Log
## [0.7](https://github.com/kevinpapst/kimai2/tree/0.7) (2019-01-28)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.6.1...0.7)
**Implemented enhancements:**
- Editing from calendar view will return to my times table instead of calendar [\#515](https://github.com/kevinpapst/kimai2/issues/515)
- Timesheet Export for Admins [\#503](https://github.com/kevinpapst/kimai2/issues/503)
- Customer List Not Alphabetic [\#499](https://github.com/kevinpapst/kimai2/issues/499)
- sorting of ${entry.X} values [\#487](https://github.com/kevinpapst/kimai2/issues/487)
- ${entry.description} needed in Word-Docx [\#485](https://github.com/kevinpapst/kimai2/issues/485)
- Automatic sort for activities, customers, etc. [\#477](https://github.com/kevinpapst/kimai2/issues/477)
- User title in the timesheet invoice [\#461](https://github.com/kevinpapst/kimai2/issues/461)
- global variables for reports/invoices [\#438](https://github.com/kevinpapst/kimai2/issues/438)
- Configuration option: Only one active record for each user [\#427](https://github.com/kevinpapst/kimai2/issues/427)
- User creating activity and projects [\#393](https://github.com/kevinpapst/kimai2/issues/393)
- Configuration option to disable fixed rate and hourly rate from "edit timesheet" [\#330](https://github.com/kevinpapst/kimai2/issues/330)
- Set other users hourly rate [\#303](https://github.com/kevinpapst/kimai2/issues/303)
- Feature request - Make "Rate" hideable [\#217](https://github.com/kevinpapst/kimai2/issues/217)
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- This value should be greater than or equal to zero [\#511](https://github.com/kevinpapst/kimai2/issues/511)
- Timesheet Export for Admins [\#503](https://github.com/kevinpapst/kimai2/issues/503)
- admin activity: visibility "none" \(no filter\) causes sql-error [\#491](https://github.com/kevinpapst/kimai2/issues/491)
- login-screen optimizations [\#483](https://github.com/kevinpapst/kimai2/issues/483)
- Configuration of roles to add/edit customers, projects, activities... [\#479](https://github.com/kevinpapst/kimai2/issues/479)
- Line breaks for address and payment information fields [\#464](https://github.com/kevinpapst/kimai2/issues/464)
- Possible to use a decimal in hourly rate field? [\#458](https://github.com/kevinpapst/kimai2/issues/458)
- Invoice Number Generator possibly not compatible [\#454](https://github.com/kevinpapst/kimai2/issues/454)
- users can change their role to system-admin [\#440](https://github.com/kevinpapst/kimai2/issues/440)
- fix wrong include filename in user registration [\#520](https://github.com/kevinpapst/kimai2/pull/520) ([kevinpapst](https://github.com/kevinpapst))
- fix segmentation fault - rollback composer dependencies [\#463](https://github.com/kevinpapst/kimai2/pull/463) ([kevinpapst](https://github.com/kevinpapst))
- fixed null project for advanced invoice calculator [\#462](https://github.com/kevinpapst/kimai2/pull/462) ([kevinpapst](https://github.com/kevinpapst))
- fix the restart timesheet button [\#436](https://github.com/kevinpapst/kimai2/pull/436) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Redirecting when using Kimai on a subdirectory + reverse proxy [\#492](https://github.com/kevinpapst/kimai2/issues/492)
- Installation \(Cannot declare.... in use\) [\#455](https://github.com/kevinpapst/kimai2/issues/455)
- New Number Generator not recognized [\#453](https://github.com/kevinpapst/kimai2/issues/453)
- de/help/invoices returns 404 error [\#452](https://github.com/kevinpapst/kimai2/issues/452)
- Can you Add Brazilian Portuguese Translation? I can help it.. [\#444](https://github.com/kevinpapst/kimai2/issues/444)
- replace all selects with smart-selects and live-search [\#441](https://github.com/kevinpapst/kimai2/issues/441)
- use parsedown-extra for rendering markdown [\#388](https://github.com/kevinpapst/kimai2/issues/388)
- Think about a cooler name [\#133](https://github.com/kevinpapst/kimai2/issues/133)
**Merged pull requests:**
- pagination without reload while keeping filters applied [\#521](https://github.com/kevinpapst/kimai2/pull/521) ([kevinpapst](https://github.com/kevinpapst))
- go back to calendar after editing and creation of time-records [\#519](https://github.com/kevinpapst/kimai2/pull/519) ([kevinpapst](https://github.com/kevinpapst))
- fetch toolbar results without page reload [\#518](https://github.com/kevinpapst/kimai2/pull/518) ([kevinpapst](https://github.com/kevinpapst))
- Form and theme improvements [\#513](https://github.com/kevinpapst/kimai2/pull/513) ([kevinpapst](https://github.com/kevinpapst))
- validation for future and negative times [\#512](https://github.com/kevinpapst/kimai2/pull/512) ([kevinpapst](https://github.com/kevinpapst))
- alphabetical order for selectboxes [\#510](https://github.com/kevinpapst/kimai2/pull/510) ([kevinpapst](https://github.com/kevinpapst))
- Export team timesheets [\#508](https://github.com/kevinpapst/kimai2/pull/508) ([kevinpapst](https://github.com/kevinpapst))
- fix broken sql in activity admin [\#506](https://github.com/kevinpapst/kimai2/pull/506) ([kevinpapst](https://github.com/kevinpapst))
- display invoice entries in ascending order [\#505](https://github.com/kevinpapst/kimai2/pull/505) ([kevinpapst](https://github.com/kevinpapst))
- Improve DOCX template row [\#504](https://github.com/kevinpapst/kimai2/pull/504) ([kevinpapst](https://github.com/kevinpapst))
- do not display admin menu if it has no children [\#500](https://github.com/kevinpapst/kimai2/pull/500) ([kevinpapst](https://github.com/kevinpapst))
- login-screen optimizations [\#493](https://github.com/kevinpapst/kimai2/pull/493) ([lduer](https://github.com/lduer))
- preg\_replace for md-file-extensions when rendering correct link… [\#482](https://github.com/kevinpapst/kimai2/pull/482) ([lduer](https://github.com/lduer))
- Better composer install in the docker [\#481](https://github.com/kevinpapst/kimai2/pull/481) ([tobybatch](https://github.com/tobybatch))
- Update dockerfile for https in composer installer [\#478](https://github.com/kevinpapst/kimai2/pull/478) ([scolson](https://github.com/scolson))
- support line breaks in docx [\#466](https://github.com/kevinpapst/kimai2/pull/466) ([kevinpapst](https://github.com/kevinpapst))
- updated README and docu [\#465](https://github.com/kevinpapst/kimai2/pull/465) ([kevinpapst](https://github.com/kevinpapst))
- allow decimals in users hourly rate [\#460](https://github.com/kevinpapst/kimai2/pull/460) ([kevinpapst](https://github.com/kevinpapst))
- updated all composer packages [\#459](https://github.com/kevinpapst/kimai2/pull/459) ([kevinpapst](https://github.com/kevinpapst))
- moved some packages to dev requirements [\#456](https://github.com/kevinpapst/kimai2/pull/456) ([kevinpapst](https://github.com/kevinpapst))
- Added portuguese translations [\#446](https://github.com/kevinpapst/kimai2/pull/446) ([marquesmatheus](https://github.com/marquesmatheus))
- Simplify timesheet edit form if only one customer is existing [\#443](https://github.com/kevinpapst/kimai2/pull/443) ([kevinpapst](https://github.com/kevinpapst))
- added project variables for invoice templates [\#439](https://github.com/kevinpapst/kimai2/pull/439) ([kevinpapst](https://github.com/kevinpapst))
- added configurable permission system [\#424](https://github.com/kevinpapst/kimai2/pull/424) ([kevinpapst](https://github.com/kevinpapst))
- Only one running timesheet: automatically stop others [\#386](https://github.com/kevinpapst/kimai2/pull/386) ([lduer](https://github.com/lduer))
## [0.6.1](https://github.com/kevinpapst/kimai2/tree/0.6.1) (2018-11-19)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.6...0.6.1)
**Fixed bugs:**
- Error importing v1 Database [\#428](https://github.com/kevinpapst/kimai2/issues/428)
- Redo button missing? [\#426](https://github.com/kevinpapst/kimai2/issues/426)
**Closed issues:**
- Error importing v1 Database \(could not convert to string\) [\#431](https://github.com/kevinpapst/kimai2/issues/431)
**Merged pull requests:**
- fix migration for MySQL [\#430](https://github.com/kevinpapst/kimai2/pull/430) ([kevinpapst](https://github.com/kevinpapst))
- fix importer for MySQL [\#429](https://github.com/kevinpapst/kimai2/pull/429) ([kevinpapst](https://github.com/kevinpapst))
## [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)
**Implemented enhancements:**
- Language setting does not work immediately [\#418](https://github.com/kevinpapst/kimai2/issues/418)
- Merge projects and/or activities [\#406](https://github.com/kevinpapst/kimai2/issues/406)
- Global activities support for Kimai 1 importer [\#400](https://github.com/kevinpapst/kimai2/issues/400)
- 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)
- Activities just Duration [\#334](https://github.com/kevinpapst/kimai2/issues/334)
- API: Add filtering/sorting/limits [\#333](https://github.com/kevinpapst/kimai2/issues/333)
- 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)
- 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)
**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:**
- 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)
- 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)
- Unrecognized Options "renderer, number\_generator" under "kimai.invoice" [\#381](https://github.com/kevinpapst/kimai2/issues/381)
- 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)
- 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)
- docker image for kimai2 [\#284](https://github.com/kevinpapst/kimai2/issues/284)
**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)
**Implemented enhancements:**
- 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 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)
**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)
- Add permission voter for invoices and templates [\#293](https://github.com/kevinpapst/kimai2/issues/293)
**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)
**Implemented enhancements:**
- Delete user [\#225](https://github.com/kevinpapst/kimai2/issues/225)
**Fixed bugs:**
- Don't allow to stop an already stopped entry [\#282](https://github.com/kevinpapst/kimai2/issues/282)
- installed it, and then what??? [\#255](https://github.com/kevinpapst/kimai2/issues/255)
- fixed calendar for empty timesheet descriptions [\#265](https://github.com/kevinpapst/kimai2/pull/265) ([kevinpapst](https://github.com/kevinpapst))
- fixed .htaccess for apache users [\#260](https://github.com/kevinpapst/kimai2/pull/260) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- 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)
- Feature request - Monthly overview / report [\#207](https://github.com/kevinpapst/kimai2/issues/207)
**Merged pull requests:**
- fixed money display in dashboard widgets [\#291](https://github.com/kevinpapst/kimai2/pull/291) ([kevinpapst](https://github.com/kevinpapst))
- Symfony update 4.1.4 [\#290](https://github.com/kevinpapst/kimai2/pull/290) ([kevinpapst](https://github.com/kevinpapst))
- do not allow to stop already stopped timesheets [\#289](https://github.com/kevinpapst/kimai2/pull/289) ([kevinpapst](https://github.com/kevinpapst))
- added configurable formats for duration [\#287](https://github.com/kevinpapst/kimai2/pull/287) ([kevinpapst](https://github.com/kevinpapst))
- Updated russian translations [\#286](https://github.com/kevinpapst/kimai2/pull/286) ([kevinpapst](https://github.com/kevinpapst))
- Update french translation [\#279](https://github.com/kevinpapst/kimai2/pull/279) ([jeau](https://github.com/jeau))
- Fix standard date format in french [\#278](https://github.com/kevinpapst/kimai2/pull/278) ([jeau](https://github.com/jeau))
- new translations: spanish, french and italian [\#276](https://github.com/kevinpapst/kimai2/pull/276) ([kevinpapst](https://github.com/kevinpapst))
- French translation for Kimai [\#275](https://github.com/kevinpapst/kimai2/pull/275) ([jeau](https://github.com/jeau))
- updated to AdminLTE 2.4.8 [\#274](https://github.com/kevinpapst/kimai2/pull/274) ([kevinpapst](https://github.com/kevinpapst))
- fixing migration\_v1.md url [\#270](https://github.com/kevinpapst/kimai2/pull/270) ([ltsavar](https://github.com/ltsavar))
- dashboard widgets are configurable via config [\#269](https://github.com/kevinpapst/kimai2/pull/269) ([kevinpapst](https://github.com/kevinpapst))
- Spanish translation for Kimai [\#266](https://github.com/kevinpapst/kimai2/pull/266) ([cobaltos](https://github.com/cobaltos))
- moved theme settings to dynamic compiler pass [\#264](https://github.com/kevinpapst/kimai2/pull/264) ([kevinpapst](https://github.com/kevinpapst))
- Added config setting to disable password-reset and user-registration [\#261](https://github.com/kevinpapst/kimai2/pull/261) ([kevinpapst](https://github.com/kevinpapst))
- Added basic API endpoints [\#258](https://github.com/kevinpapst/kimai2/pull/258) ([kevinpapst](https://github.com/kevinpapst))
- Hotfix for Symfony Flex incompatibility with latest composer changes [\#257](https://github.com/kevinpapst/kimai2/pull/257) ([kevinpapst](https://github.com/kevinpapst))
- added italian translation [\#253](https://github.com/kevinpapst/kimai2/pull/253) ([kevinpapst](https://github.com/kevinpapst))
- Delete user [\#249](https://github.com/kevinpapst/kimai2/pull/249) ([kevinpapst](https://github.com/kevinpapst))
- dashboard widgets from event [\#246](https://github.com/kevinpapst/kimai2/pull/246) ([kevinpapst](https://github.com/kevinpapst))
- Added timesheet-calendar view [\#236](https://github.com/kevinpapst/kimai2/pull/236) ([kevinpapst](https://github.com/kevinpapst))
- Improved data fixtures [\#234](https://github.com/kevinpapst/kimai2/pull/234) ([kevinpapst](https://github.com/kevinpapst))
## [0.3](https://github.com/kevinpapst/kimai2/tree/0.3) (2018-07-22)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.2...0.3)
**Update from 0.2:**
**Implemented enhancements:**
You need to adjust your `.env` file and add your `from` address for [all emails](https://github.com/kevinpapst/kimai2/blob/master/var/docs/emails.md) generated by Kimai 2:
```
MAILER_FROM=kimai@example.com
```
Users upgrading from 0.2 need to execute:
```
bin/console doctrine:migrations:version --add 20180701120000
bin/console doctrine:migrations:migrate
```
- Install Kimai as WebApp [\#203](https://github.com/kevinpapst/kimai2/issues/203)
- Replace AvanzuAdminTheme with AdminLTE bundle [\#201](https://github.com/kevinpapst/kimai2/issues/201)
- Add dynamic column filter [\#174](https://github.com/kevinpapst/kimai2/issues/174)
- Add register user function [\#164](https://github.com/kevinpapst/kimai2/issues/164)
- Add forgot password function - login screen [\#163](https://github.com/kevinpapst/kimai2/issues/163)
- Integrate FOSUserBundle \(for registration, password-reset, etc.\) [\#144](https://github.com/kevinpapst/kimai2/issues/144)
- added dynamic column filter \#174 [\#184](https://github.com/kevinpapst/kimai2/pull/184) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
@@ -25,20 +282,14 @@ bin/console doctrine:migrations:migrate
**Closed issues:**
- Verifying email configuration [\#226](https://github.com/kevinpapst/kimai2/issues/226)
- Installation Issue of version1.3.1 [\#208](https://github.com/kevinpapst/kimai2/issues/208)
- Error on bin/console doctrine:schema:create [\#191](https://github.com/kevinpapst/kimai2/issues/191)
- Verifying email configuration [\#226](https://github.com/kevinpapst/kimai2/issues/226)
- Add favicon [\#205](https://github.com/kevinpapst/kimai2/issues/205)
- Install Kimai as WebApp [\#203](https://github.com/kevinpapst/kimai2/issues/203)
- Replace AvanzuAdminTheme with AdminLTE bundle [\#201](https://github.com/kevinpapst/kimai2/issues/201)
- Document DB requirements in install docu [\#196](https://github.com/kevinpapst/kimai2/issues/196)
- easier creation of tasks [\#195](https://github.com/kevinpapst/kimai2/issues/195)
- Error on bin/console doctrine:schema:create [\#191](https://github.com/kevinpapst/kimai2/issues/191)
- Integrate lock bot [\#183](https://github.com/kevinpapst/kimai2/issues/183)
- Upgrade to FontAwesome 5 [\#179](https://github.com/kevinpapst/kimai2/issues/179)
- Add dynamic column filter [\#174](https://github.com/kevinpapst/kimai2/issues/174)
- Add register user function [\#164](https://github.com/kevinpapst/kimai2/issues/164)
- Add forgot password function - login screen [\#163](https://github.com/kevinpapst/kimai2/issues/163)
- Integrate FOSUserBundle \(for registration, password-reset, etc.\) [\#144](https://github.com/kevinpapst/kimai2/issues/144)
**Merged pull requests:**
@@ -57,42 +308,24 @@ bin/console doctrine:migrations:migrate
- improved install docs and composer [\#187](https://github.com/kevinpapst/kimai2/pull/187) ([kevinpapst](https://github.com/kevinpapst))
- added lock-bot config \#183 [\#186](https://github.com/kevinpapst/kimai2/pull/186) ([kevinpapst](https://github.com/kevinpapst))
- Composer package name [\#185](https://github.com/kevinpapst/kimai2/pull/185) ([kevinpapst](https://github.com/kevinpapst))
- added dynamic column filter \#174 [\#184](https://github.com/kevinpapst/kimai2/pull/184) ([kevinpapst](https://github.com/kevinpapst))
- updated to font-awesome 5 \#179 [\#181](https://github.com/kevinpapst/kimai2/pull/181) ([kevinpapst](https://github.com/kevinpapst))
- language specific money and date display [\#180](https://github.com/kevinpapst/kimai2/pull/180) ([kevinpapst](https://github.com/kevinpapst))
## [0.2](https://github.com/kevinpapst/kimai2/tree/0.2) (2018-06-23)
[Full Changelog](https://github.com/kevinpapst/kimai2/compare/0.1...0.2)
**Fixed bugs:**
- Change to users language after login [\#7](https://github.com/kevinpapst/kimai2/issues/7)
- Prevent end time before start time [\#152](https://github.com/kevinpapst/kimai2/issues/152)
- Importing old data fails [\#145](https://github.com/kevinpapst/kimai2/issues/145)
- Translate LanguageType choices [\#83](https://github.com/kevinpapst/kimai2/issues/83)
- Mobile alignments and toolbar \#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
**Implemented enhancements:**
- Add english translation [\#8](https://github.com/kevinpapst/kimai2/issues/8)
- Add configurable system configuration [\#6](https://github.com/kevinpapst/kimai2/issues/6)
- Add CHANGELOG.md [\#177](https://github.com/kevinpapst/kimai2/issues/177)
- Hide notifications after some seconds [\#169](https://github.com/kevinpapst/kimai2/issues/169)
- Add preview blog message and info text on old website [\#162](https://github.com/kevinpapst/kimai2/issues/162)
- Setup Scrutinizer to run code sniffer [\#158](https://github.com/kevinpapst/kimai2/issues/158)
- Local config to overwrite settings [\#153](https://github.com/kevinpapst/kimai2/issues/153)
- Improve mobile view [\#151](https://github.com/kevinpapst/kimai2/issues/151)
- Upgrade to Symfony 4.1 [\#149](https://github.com/kevinpapst/kimai2/issues/149)
- Replace has\_role\(\) with is\_granted\(\) [\#148](https://github.com/kevinpapst/kimai2/issues/148)
- Add help text to form elements [\#138](https://github.com/kevinpapst/kimai2/issues/138)
- Duration mode: creating new timesheet records prefills duration with some seconds [\#137](https://github.com/kevinpapst/kimai2/issues/137)
- Add duration-only mode [\#131](https://github.com/kevinpapst/kimai2/issues/131)
- Add help controller [\#129](https://github.com/kevinpapst/kimai2/issues/129)
- \[RFC\] Encore: configureFilenames with hash after filename instead of in the filename [\#125](https://github.com/kevinpapst/kimai2/issues/125)
- \[RFC\] Kimai license header [\#124](https://github.com/kevinpapst/kimai2/issues/124)
- Secure create-user command [\#123](https://github.com/kevinpapst/kimai2/issues/123)
- Setup travis to run phpunit tests [\#120](https://github.com/kevinpapst/kimai2/issues/120)
- Add command for building a production release [\#115](https://github.com/kevinpapst/kimai2/issues/115)
- Add webpack and webpack-encore for frontend assets [\#113](https://github.com/kevinpapst/kimai2/issues/113)
- Add time rounding option [\#112](https://github.com/kevinpapst/kimai2/issues/112)
@@ -102,7 +335,6 @@ bin/console doctrine:migrations:migrate
- Add importer from Kimai v1 [\#102](https://github.com/kevinpapst/kimai2/issues/102)
- Setup error pages [\#100](https://github.com/kevinpapst/kimai2/issues/100)
- Add basic HTML invoice rendering [\#97](https://github.com/kevinpapst/kimai2/issues/97)
- Add russian translation [\#95](https://github.com/kevinpapst/kimai2/issues/95)
- Filter user in timesheet admin [\#93](https://github.com/kevinpapst/kimai2/issues/93)
- Support to switch theme settings [\#89](https://github.com/kevinpapst/kimai2/issues/89)
- Roles should become a real table [\#86](https://github.com/kevinpapst/kimai2/issues/86)
@@ -111,10 +343,35 @@ bin/console doctrine:migrations:migrate
- Add remember me login [\#53](https://github.com/kevinpapst/kimai2/issues/53)
- Add preferences sidebar [\#10](https://github.com/kevinpapst/kimai2/issues/10)
- Add user settings [\#9](https://github.com/kevinpapst/kimai2/issues/9)
- Mobile alignments and toolbar \#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Local config \#153 [\#156](https://github.com/kevinpapst/kimai2/pull/156) ([kevinpapst](https://github.com/kevinpapst))
- Filter in hidden toolbar - improved mobile \#151 [\#155](https://github.com/kevinpapst/kimai2/pull/155) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Fixed bugs:**
- Change to users language after login [\#7](https://github.com/kevinpapst/kimai2/issues/7)
- Prevent end time before start time [\#152](https://github.com/kevinpapst/kimai2/issues/152)
- Importing old data fails [\#145](https://github.com/kevinpapst/kimai2/issues/145)
- Translate LanguageType choices [\#83](https://github.com/kevinpapst/kimai2/issues/83)
- Mobile alignments and toolbar \\#151 [\#157](https://github.com/kevinpapst/kimai2/pull/157) ([kevinpapst](https://github.com/kevinpapst))
- Improved mobile view \\#151 [\#150](https://github.com/kevinpapst/kimai2/pull/150) ([kevinpapst](https://github.com/kevinpapst))
**Closed issues:**
- Add CHANGELOG.md [\#177](https://github.com/kevinpapst/kimai2/issues/177)
- Add preview blog message and info text on old website [\#162](https://github.com/kevinpapst/kimai2/issues/162)
- Upgrade to Symfony 4.1 [\#149](https://github.com/kevinpapst/kimai2/issues/149)
- Replace has\_role\(\) with is\_granted\(\) [\#148](https://github.com/kevinpapst/kimai2/issues/148)
- Duration mode: creating new timesheet records prefills duration with some seconds [\#137](https://github.com/kevinpapst/kimai2/issues/137)
- \[RFC\] Encore: configureFilenames with hash after filename instead of in the filename [\#125](https://github.com/kevinpapst/kimai2/issues/125)
- \[RFC\] Kimai license header [\#124](https://github.com/kevinpapst/kimai2/issues/124)
- Setup travis to run phpunit tests [\#120](https://github.com/kevinpapst/kimai2/issues/120)
- Add russian translation [\#95](https://github.com/kevinpapst/kimai2/issues/95)
**Merged pull requests:**
- prepare v0.2 release \#177 [\#178](https://github.com/kevinpapst/kimai2/pull/178) ([kevinpapst](https://github.com/kevinpapst))
- \[WIP\] Translations [\#173](https://github.com/kevinpapst/kimai2/pull/173) ([simonschaufi](https://github.com/simonschaufi))
- 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))
@@ -126,8 +383,6 @@ bin/console doctrine:migrations:migrate
- added scrutinizer config \#158 [\#161](https://github.com/kevinpapst/kimai2/pull/161) ([kevinpapst](https://github.com/kevinpapst))
- Update to Symfony 4.1 \#149 [\#160](https://github.com/kevinpapst/kimai2/pull/160) ([kevinpapst](https://github.com/kevinpapst))
- Updated README [\#159](https://github.com/kevinpapst/kimai2/pull/159) ([kevinpapst](https://github.com/kevinpapst))
- Local config \#153 [\#156](https://github.com/kevinpapst/kimai2/pull/156) ([kevinpapst](https://github.com/kevinpapst))
- Filter in hidden toolbar - improved mobile \#151 [\#155](https://github.com/kevinpapst/kimai2/pull/155) ([kevinpapst](https://github.com/kevinpapst))
- added russian translation \#95 [\#147](https://github.com/kevinpapst/kimai2/pull/147) ([kevinpapst](https://github.com/kevinpapst))
- Bugfix importer \(\#145\) [\#146](https://github.com/kevinpapst/kimai2/pull/146) ([kevinpapst](https://github.com/kevinpapst))
- help text and link for form labels \#138 [\#143](https://github.com/kevinpapst/kimai2/pull/143) ([kevinpapst](https://github.com/kevinpapst))
@@ -159,7 +414,7 @@ bin/console doctrine:migrations:migrate
- upgraded to symfony 4 \#74 [\#81](https://github.com/kevinpapst/kimai2/pull/81) ([kevinpapst](https://github.com/kevinpapst))
## [0.1](https://github.com/kevinpapst/kimai2/tree/0.1) (2018-01-10)
**Closed issues:**
**Implemented enhancements:**
- Hide inactive delete user button [\#78](https://github.com/kevinpapst/kimai2/issues/78)
- Delete activity [\#71](https://github.com/kevinpapst/kimai2/issues/71)

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# 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
RUN apt update && \
apt install -y \
git \
libicu-dev \
libjpeg-dev \
libldap2-dev \
libldb-dev \
libpng-dev \
unzip \
wget \
zip \
&& \
EXPECTED_SIGNATURE="$(wget -q -O - https://composer.github.io/installer.sig)" && \
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
ACTUAL_SIGNATURE="$(php -r "echo hash_file('sha384', 'composer-setup.php');")" && \
if [ "$EXPECTED_SIGNATURE" != "$ACTUAL_SIGNATURE" ]; then >&2 echo 'ERROR: Invalid installer signature'; rm composer-setup.php; exit 1; fi && \
php composer-setup.php --quiet && \
rm composer-setup.php && \
mv /var/www/html/composer.phar /usr/bin/composer && \
docker-php-ext-install \
gd \
intl \
ldap \
pdo_mysql \
zip && \
apt remove -y wget && \
apt -y autoremove && \
apt clean && \
git clone https://github.com/kevinpapst/kimai2.git /opt/kimai && \
sed "s/prod/dev/g" /opt/kimai/.env.dist > /opt/kimai/.env && \
composer install --working-dir=/opt/kimai --dev --optimize-autoloader && \
/opt/kimai/bin/console doctrine:database:create && \
/opt/kimai/bin/console doctrine:schema:create && \
/opt/kimai/bin/console doctrine:migrations:version --add --all && \
/opt/kimai/bin/console cache:warmup && \
chown -R www-data:www-data /opt/kimai/var
WORKDIR /opt/kimai
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.

160
README.md
View File

@@ -1,6 +1,6 @@
# Kimai v2 - Time Tracking
# Kimai 2 - Time-tracking made easy
Kimai v2 - the open source time-tracking application with a mobile-first approach, read more at the [official website](http://v2.kimai.org).
Kimai - the open source time-tracker application with a mobile-first approach (read more at the [official website](http://v2.kimai.org)).
[![Latest Stable Version](https://poser.pugx.org/kevinpapst/kimai2/v/stable)](https://packagist.org/packages/kevinpapst/kimai2)
[![License](https://poser.pugx.org/kevinpapst/kimai2/license)](https://packagist.org/packages/kevinpapst/kimai2)
@@ -11,141 +11,58 @@ Kimai v2 - the open source time-tracking application with a mobile-first approac
## Introduction
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.
This is the _reloaded_ version of the open source timetracker Kimai.
It is built from scratch and doesn't share any source code with its [predecessor](http://www.kimai.org).
But it adapts the same ideas and a clean & simple UI for your time-tracking experience.
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.
By now it is in an pre-stable development phase, usable and with most advanced features from Kimai 1.
You can even [import your data](var/docs/migration_v1.md) and start testing and using it today.
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](var/docs/translations.md) and already translated to english, german, italian, french, spanish, russian, arabic, hungarian and portuguese.
### 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 [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))
- PHP 7.1.3 or higher (test your system compatibility with the [requirements-checker](http://symfony.com/doc/current/reference/requirements.html))
- The PHP extensions [intl](https://php.net/manual/en/book.intl.php), [zip](https://php.net/manual/en/book.zip.php) and [PDO](https://php.net/manual/en/book.pdo.php) with either [pdo_sqlite](https://php.net/manual/en/ref.pdo-sqlite.php) or [pdo_mysql](https://php.net/manual/en/ref.pdo-mysql.php) enabled
- 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 9
- A modern browser, Kimai v2 might be broken on old browsers like IE 10
## Documentation & Roadmap
## Documentation
Looking for more information about using Kimai? Check out our more detailed [documentation](var/docs/).
Looking for more information about Kimai 2? Check out our detailed [documentation](var/docs/).
You can see our development roadmap for the future in the [Milestones](milestones/) sections,
current work is organized in the [Project](projects/) planning boards.
Our roadmap is open for changes and input from the community, please [sent us](issues/) your ideas and questions.
### Installation
## Installation
There are multiple ways to install Kimai, all of them described in the [installation docu](var/docs/installation.md):
There are [further infos about installation](var/docs/installation.md) if you have to use FTP or want to develop with Kimai.
- [Recommended installation with GIT and Composer](var/docs/installation.md#recommended-setup)
- [Development setup](var/docs/installation.md#development-installation)
- [Docker](var/docs/docker.md)
- [1-click installations](var/docs/installation.md#hosting-and-1-click-installations)
- [FTP](var/docs/installation.md#ftp-installation)
If you want to install Kimai v2 in your production environment, then SSH into your server and change to your webserevr root.
You need to install Git and [Composer](https://getcomposer.org/doc/00-intro.md) if you haven't already.
### Updating Kimai
First clone this repo:
Read the following documentations before you start your upgrade:
```bash
git clone https://github.com/kevinpapst/kimai2.git
cd kimai2/
```
- The [update documentation](var/docs/updates.md)
- The version specific [UPGRADING guide](UPGRADING.md)
- The [release information](https://github.com/kevinpapst/kimai2/releases)
Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html):
```bash
chown -R www-data var/
chmod -R 777 var/
```
## Roadmap and releases
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite.
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
```
You can see our development roadmap in the [Milestones](https://github.com/kevinpapst/kimai2/milestones) sections.
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome!
Now install all dependencies for Kimai 2:
> Kimai 2 uses a rolling release concept for delivering updates.
> You can upgrade Kimai at any time, you don't need to wait for the next official release.
```bash
sudo -u www-data composer install --no-dev --optimize-autoloader
```
Optionally create the database:
```bash
bin/console doctrine:database:create
```
Create all schema tables:
```bash
bin/console doctrine:schema:create
```
Make sure that upcoming updates can be correctly applied by setting the initial database version:
```bash
bin/console doctrine:migrations:version --add --all
```
Warm up the cache (as webserver user):
```bash
sudo -u www-data bin/console cache:warmup --env=prod
```
Create your first user with the following command. You will be asked to enter a password afterwards:
```bash
bin/console kimai:create-user username admin@example.com ROLE_SUPER_ADMIN
```
_Tip: You can skip the "create user" step, if you are going to [import data from Kimai v1](migration_v1.md)._
For available roles, please refer to the [user documentation](var/docs/users.md).
> **NOTE**
>
> If you want to use a fully-featured web server (like Nginx or Apache) to run
> Kimai, configure it to point at the `public/` directory of the project.
> For more details, see:
> http://symfony.com/doc/current/cookbook/configuration/web_server_configuration.html
Installation complete: enjoy time-tracking :-)
## Updating Kimai
**STOP**
1. It's important that you don't execute the Installation steps before or after your upgrade
2. Make sure that you have a working database backup before you start the update
3. Read the [UPGRADING](UPGRADING.md) guide and [release information](https://github.com/kevinpapst/kimai2/releases) to check if there a further steps required
Get the latest code and install dependencies:
```bash
cd kimai2/
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader
```
Refresh your cache:
```bash
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
```
And upgrade your database:
```bash
bin/console doctrine:migrations:migrate
```
Done! You can use the latest version of Kimai 2.
## Rolling releases & Git
Please note: Kimai 2 uses a rolling release concept for delivering updates.
Release versions will be created on a regular base and you can use these tags if you are familiar with Git, but we
will not provide support for any specific version (whether its bugs or installation/update docu).
Every code change, whether it's a new features or bug fixes, will be targeted against the master branch and
intensively tested before merging. We have to go this way, as we develop Kimai in our free time and want to put our
effort into the software instead of installation scripts and complicated upgrade processes.
Release versions will be created on a regular base and you can use these tags if you are familiar with Git,
but we will not provide support for any specific version.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch and
intensively tested before merging. We have to do it this way, as we develop Kimai in our free time and want to put our
effort into the software instead of backporting changes for old versions.
## Extensions for Kimai 2
@@ -156,7 +73,10 @@ All available Kimai 2 bundles can be found at the [Kimai recipes](https://github
## Developer
If you want to develop with and for Kimai 2 please read the following documentation:
Kimai 2 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).
If you want to start developing for Kimai 2, please read the following documentation:
- an example on how to extend Kimai 2 can be found in this [GitHub repository](https://github.com/kevinpapst/kimai2-invoice)
- the [developer documentation](var/docs/developers.md) is available both on GitHub and your local installation

View File

@@ -1,5 +1,75 @@
# Upgrading Kimai 2
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
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
bin/console doctrine:migrations:migrate
```
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.7](https://github.com/kevinpapst/kimai2/releases/tag/0.7) (2019-01-28)
The configuration `kimai.theme.active_warning` was deprecated and should be replaced in your local.yaml,
[read config docs for more information](https://github.com/kevinpapst/kimai2/blob/master/var/docs/configurations.md#limit-active-entries).
## [0.6.1](https://github.com/kevinpapst/kimai2/releases/tag/0.6.1) (2018-11-19)
A bugfix release to address database compatibility issues with older MySQL/MariaDB versions.
## [0.6](https://github.com/kevinpapst/kimai2/releases/tag/0.6) (2018-11-18)
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,
which leads to problems between Composer and Symfony Flex, resulting in an error like this when running it:
```
[ErrorException]
Declaration of Symfony\Flex\ParallelDownloader::getRemoteContents($originUrl, $fileUrl, $context) should be compatible with Composer\Util\RemoteFilesystem::getRemoteContents($originUrl, $fileUrl, $context, ?array &$responseHeaders = NULL)
```
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 --no-scripts
```
Then the full update can be executed as usual:
```bash
git pull origin master
sudo -u www-data composer install --no-dev --optimize-autoloader
sudo -u www-data bin/console cache:clear --env=prod
sudo -u www-data bin/console cache:warmup --env=prod
bin/console doctrine:migrations:migrate
```
## [0.3](https://github.com/kevinpapst/kimai2/releases/tag/0.3) (2018-07-22)
**Update from 0.2:**

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

@@ -1,156 +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.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
.error-page {
margin-bottom: 50px;
}
/* ================================ CONTENT ================================ */
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@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 available on most pages above the datatable */
.toolbar form {
font-size: $font-size-base;
}
/* ================================ 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%;
}
/* ================================ 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;
}

BIN
assets/images/signature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,10 +0,0 @@
require('../../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
require('../css/app.scss');
require('../css/datatable.scss');
// ------ 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,8 +29,11 @@ $(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) {
$('body').on('click', 'a.btn-trash', 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,19 +78,53 @@ $(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");
});
/*
$('input').iCheck({
checkboxClass: 'icheckbox_square-blue',
radioClass: 'iradio_square-blue',
increaseArea: '20%' // optional
});
*/
$('select[data-related-select]').change(function() {
var apiUrl = $(this).attr('data-api-url').replace('-s-', $(this).val());
var targetSelect = $(this).attr('data-related-select');
// $.AdminLTE.pushMenu.expandOnHover();
$.ajax({
url: apiUrl,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
method: 'GET',
dataType: 'json',
success: function(data){
var selectName = '#' + targetSelect;
var $select = $(selectName);
var $emptyOption = $(selectName + ' option[value=""]');
$select.find('option').remove().end().find('optgroup').remove().end();
if ($emptyOption.length != 0) {
$select.append('<option value="">' + $emptyOption.text() + '</option>');
}
$.each(data, function(i, obj) {
$select.append('<option value="' + obj.id + '">' + obj.name + '</option>');
});
$select.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();
});
},
pauseRecord: function(selector) {
$(selector + ' .pull-left i').hover(function () {
@@ -98,8 +141,7 @@ $(function() {
// default values
$.kimai.defaults = {
baseUrl: '/',
imagePath: '/images',
locale: 'en',
confirmDelete: 'Really delete?',
alertSuccessAutoHide: 5000
};

View File

@@ -6,32 +6,51 @@
*/
$(document).ready(function () {
//$('.navbar-form select').selectpicker({});
/* Submit the pagination including the toolbar filters */
$('body').on('click', 'div.navigation ul.pagination li a', function(event) {
var $pager = $(".toolbar form input[name='page']");
if ($pager.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
var $urlParts = $(this).attr('href').split('/');
var page = $urlParts[$urlParts.length-1];
$pager.val(page);
$pager.trigger('change');
return false;
});
// DateRange - TODO improve me, so users can type without reloading in between
$('.toolbar form input').change(function (event) {
$('.toolbar form').submit();
toolbarLoadDataAfterChange();
});
$('.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#activity').parent().remove();
break;
case 'project':
if ($(this).val() === '') {
$('.toolbar form select#activity').parent().remove();
} else {
$('.toolbar form select#activity').val('');
}
case 'page':
break;
default:
$('.toolbar form input#page').val(1);
}
$('.toolbar form').submit();
toolbarLoadDataAfterChange();
});
function toolbarLoadDataAfterChange()
{
var $form = $('.toolbar form');
$.ajax({
url: $form.attr('action'),
type: $form.attr('method'),
data: $form.serialize(),
success: function(html) {
$('section.content').replaceWith(
$(html).find('section.content')
);
},
error: function(xhr, err) {
$form.submit();
}
});
}
});

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

@@ -0,0 +1,7 @@
.popover-content {
.calendar-entry {
ul {
padding: 0 0 0 10px;
}
}
}

41
assets/sass/content.scss 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.
*/
.content {
padding: 15px 0;
}
.content-header {
h1 {
small {
display: none;
}
}
}
@media (min-width: $screen-sm-min) {
.content-header {
h1 {
small {
display: inline-block;
}
}
}
.content {
padding: 15px;
}
}
td {
&.timesheet-description {
ul {
padding-left: 15px;
}
p {
margin: 0;
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* 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.
*/
section {
&.dashboard {
.info-box {
.info-box-text {
white-space: normal;
}
}
}
}

View File

@@ -222,3 +222,15 @@ div.dataTables_scrollHead table.table-bordered {
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
background-color: #017ebc;
}
/*
Make sure that the action buttons do not line-break if another column takes all available space
See https://github.com/twbs/bootstrap/issues/9939#issuecomment-37682952
*/
table.dataTable {
td {
.btn-group {
display: flex;
}
}
}

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;
}
}
}

46
assets/sass/navbar.scss Normal file
View File

@@ -0,0 +1,46 @@
/*
* 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.
*/
.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;
}
*/

47
assets/sass/toolbar.scss Normal file
View File

@@ -0,0 +1,47 @@
/*
* 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.
*/
/* 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: $screen-sm-min) {
.toolbar {
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
.control-label {
width: 100%;
}
.input-group {
width: 100%;
}
}
}
}

View File

@@ -7,48 +7,54 @@
"php": "^7.1.3",
"ext-intl": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"ext-zip": "*",
"beberlei/DoctrineExtensions": "^1.0",
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^2.3",
"friendsofsymfony/user-bundle": "~2.0",
"kevinpapst/adminlte-bundle": "^1.2",
"fzaninotto/faker": "^1.8",
"jms/serializer-bundle": "^2.4",
"kevinpapst/adminlte-bundle": "~2.1",
"nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1",
"sensio/framework-extra-bundle": "^5.1",
"phpoffice/phpspreadsheet": "^1.4",
"phpoffice/phpword": "^0.15.0",
"sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0",
"symfony/console": "^4.0",
"symfony/debug-pack": "^1.0",
"symfony/dotenv": "^4.0",
"symfony/expression-language": "^4.0",
"symfony/flex": "^1.0",
"symfony/form": "^4.0",
"symfony/framework-bundle": "^4.0",
"symfony/lts": "^4@dev",
"symfony/maker-bundle": "^1.0",
"symfony/monolog-bundle": "^3.1",
"symfony/orm-pack": "^1.0",
"symfony/profiler-pack": "^1.0",
"symfony/security-bundle": "^4.0",
"symfony/security-csrf": "^4.0",
"symfony/serializer": "^4.0",
"symfony/swiftmailer-bundle": "^3.1",
"symfony/translation": "^4.0",
"symfony/twig-bundle": "^4.0",
"symfony/validator": "^4.0",
"symfony/webpack-encore-pack": "^1.0",
"symfony/web-server-bundle": "^4.0",
"symfony/yaml": "^4.0",
"twig/extensions": "^1.5",
"white-october/pagerfanta-bundle": "^1.1"
},
"require-dev": {
"dama/doctrine-test-bundle": "^5.0",
"doctrine/doctrine-fixtures-bundle": "^3.0",
"friendsofphp/php-cs-fixer": "^2.10",
"phpunit/phpunit": "^7.0",
"squizlabs/php_codesniffer": "^3.2",
"symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0",
"symfony/debug-pack": "^1.0",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^4.0",
"symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0"
"symfony/webpack-encore-pack": "^1.0"
},
"config": {
"preferred-install": {
@@ -90,13 +96,8 @@
"extra": {
"symfony": {
"id": "01C3FWRDJJEX9K6Y3A4XDFXPBR",
"allow-contrib": false
"allow-contrib": true,
"require": "~4.1.0"
}
},
"repositories": [
{
"type": "vcs",
"url": "https://github.com/kevinpapst/AdminThemeBundle"
}
]
}
}

4016
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,4 +19,7 @@ return [
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
KevinPapst\AdminLTEBundle\AdminLTEBundle::class => ['all' => true],
FOS\UserBundle\FOSUserBundle::class => ['all' => true],
FOS\RestBundle\FOSRestBundle::class => ['all' => true],
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
];

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

@@ -0,0 +1,7 @@
jms_serializer:
visitors:
json:
options:
- JSON_PRETTY_PRINT
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@@ -0,0 +1,107 @@
fos_rest:
# disable_csrf_role: IS_AUTHENTICATED_ANONYMOUSLY
# access_denied_listener:
# enabled: false
# service: null
# formats:
# name: ~
# unauthorized_challenge: null
param_fetcher_listener:
enabled: true
# force: false
# service: null
cache_dir: '%kernel.cache_dir%/fos_rest'
# allowed_methods_listener:
# enabled: false
# service: null
routing_loader:
default_format: json
include_format: true
# prefix_methods: true
body_converter:
enabled: true
# validate: false
# validation_errors_argument: validationErrors
# service:
# router: router
# templating: templating
# serializer: null
# view_handler: fos_rest.view_handler.default
# inflector: fos_rest.inflector.doctrine
# validator: validator
# serializer:
# version: null
# groups: []
# serialize_null: false
view:
default_engine: twig
# force_redirects:
# name: ~
# mime_types:
# enabled: false
# service: null
# formats:
# name: ~
# formats:
# name: ~
# templating_formats:
# name: ~
view_response_listener:
enabled: true
# force: true
# service: null
failed_validation: 400
# empty_content: 204
# serialize_null: false
# jsonp_handler:
# callback_param: callback
# mime_type: application/javascript+jsonp
exception:
enabled: true
exception_controller: 'fos_rest.exception.controller:showAction'
# service: null
codes:
# 'Symfony\Component\Routing\Exception\ResourceNotFoundException': 404
# 'Doctrine\ORM\OptimisticLockException': HTTP_CONFLICT
# 'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': 400
messages:
'Symfony\Component\Routing\Exception\ResourceNotFoundException': true
'Symfony\Component\HttpKernel\Exception\NotFoundHttpException': true
body_listener:
enabled: true
# service: null
# default_format: null
# throw_exception_on_unsupported_content_type: false
# decoders:
# name: ~
array_normalizer:
service: null
forms: true
format_listener:
enabled: true
# service: null
rules:
- { path: ^/api, prefer_extension: true, fallback_format: json, priorities: [ json, xml ] }
# host: null # URL host name
# methods: null # Method for URL
# stop: false
# attributes: []
# versioning:
# enabled: false
# default_version: ~
# resolvers:
# query:
# enabled: true
# parameter_name: version
# custom_header:
# enabled: true
# header_name: X-Accept-Version
# media_type:
# enabled: true
# regex: /(v|version)=(?P<version>[0-9\.]+)/
# guessing_order:
# - query
# - custom_header
# - media_type
zone:
- { path: ^/api/* }

View File

@@ -0,0 +1,15 @@
jms_serializer:
handlers:
datetime:
default_format: "Y-m-d H:i"
visitors:
xml:
format_output: '%kernel.debug%'
metadata:
directories:
FOSUB:
namespace_prefix: "FOS\\UserBundle"
path: "%kernel.root_dir%/../config/serializer/FOS/UserBundle"
App:
namespace_prefix: "App"
path: "%kernel.root_dir%/../config/serializer/App"

View File

@@ -1,8 +1,18 @@
kimai:
# --------------------------------------------------------------------------------
# Settings for the user management and login forms
user:
registration: true
password_reset: true
# --------------------------------------------------------------------------------
# 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
@@ -25,28 +35,196 @@ 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'
# If you want to limit the max. active entries per user, you can do it here.
#
# The soft_limit is used as theme setting (formerly "kimai.theme.active_warning"):
# display a warning color if the user has at least X active recordings
#
# The hard_limit is used to detect how many active records are allowed per user:
# By default a user can only have one active time-record. The first one is stopped when the new one starts.
# When hard_limit is > 1: as soon as the limit is reached and warning is shown and the user has to stop one active entry.
active_entries:
soft_limit: 1
hard_limit: 3
# --------------------------------------------------------------------------------
# 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/'
# --------------------------------------------------------------------------------
# Default settings used to populate forms
#defaults:
# customer:
# timezone: Europe/Berlin
# country: DE
# currency: EUR
# --------------------------------------------------------------------------------
# Free configurable permission system, see var/docs/permissions.md
permissions:
# mapping complex rulesets of single permissions to named "sets" ("set name" = [array of "permissions"])
sets:
ACTIVITIES: [view_activity,create_activity,edit_activity,delete_activity]
PROJECTS: [view_project,create_project,edit_project,delete_project]
CUSTOMERS: [view_customer,create_customer,edit_customer,delete_customer]
INVOICE: [view_invoice,create_invoice]
INVOICE_TEMPLATE: [view_invoice_template,create_invoice_template,edit_invoice_template,delete_invoice_template]
TIMESHEET: [view_own_timesheet,start_own_timesheet,stop_own_timesheet,create_own_timesheet,edit_own_timesheet,export_own_timesheet,delete_own_timesheet]
TIMESHEET_OTHER: [view_other_timesheet,start_other_timesheet,stop_other_timesheet,create_other_timesheet,edit_other_timesheet,export_other_timesheet,delete_other_timesheet]
PROFILE: [view_own_profile,edit_own_profile,password_own_profile,preferences_own_profile,api-token_own_profile]
PROFILE_OTHER: [view_other_profile,edit_other_profile,delete_other_profile,password_other_profile,roles_other_profile,preferences_other_profile,api-token_other_profile]
USER: [view_user,create_user,delete_user]
RATE: [view_rate_own_timesheet,edit_rate_own_timesheet]
RATE_OTHER: [view_rate_other_timesheet,edit_rate_other_timesheet]
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
maps:
ROLE_USER: [TIMESHEET,PROFILE]
ROLE_TEAMLEAD: [INVOICE,TIMESHEET,TIMESHEET_OTHER,PROFILE]
ROLE_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,RATE,RATE_OTHER]
ROLE_SUPER_ADMIN: [ACTIVITIES,PROJECTS,CUSTOMERS,INVOICE,INVOICE_TEMPLATE,TIMESHEET,TIMESHEET_OTHER,PROFILE,PROFILE_OTHER,USER,RATE,RATE_OTHER]
# adding single permissions to user roles, extending the definition from "sets" ("role name" = [array of "permissions"])
roles:
ROLE_USER: []
ROLE_TEAMLEAD: [view_invoice_template,create_invoice_template,edit_invoice_template,view_rate_own_timesheet,view_rate_other_timesheet,hourly-rate_own_profile]
ROLE_ADMIN: [hourly-rate_own_profile]
ROLE_SUPER_ADMIN: [hourly-rate_own_profile,hourly-rate_other_profile,delete_own_profile,roles_own_profile]
# --------------------------------------------------------------------------------
# Language specific settings, like the date formats
languages:
de:
date_short: "d.m.Y"
date_short: 'd.m.Y'
duration: '%%h:%%m Stunden'
duration_short: '%%h:%%m h'
en:
date_short: "Y-m-d"
date_short: 'Y-m-d'
duration: '%%h:%%m hours'
duration_short: '%%h:%%m h'
pt_BR:
date_short: 'd-m-Y'
duration: '%%h:%%m horas'
duration_short: '%%h:%%m h'
it:
date_short: 'd.m.Y'
duration: '%%h:%%m h'
duration_short: '%%h:%%m h'
fr:
date_short: 'd/m/Y'
duration: '%%h h %%m min'
duration_short: '%%h h %%m'
es:
date_short: 'd.m.Y'
duration: '%%h:%%m h'
duration_short: '%%h:%%m h'
ru:
date_short: "d.m.Y"
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'
twig:
globals:
kimai_context:
box_color: "green" # a color for ???
active_warning: 3 # display a warning color if the user has at least X active recordings
# --------------------------------------------------------------------------------
# timesheet calendar configuration
calendar:
week_numbers: true
day_limit: 4
businessHours:
days: [1, 2, 3, 4, 5]
begin: '08:00'
end: '20:00'
# You can configure unlimited google calendars to display events for your company (e.g. holidays)
# Please read var/docs/configurations.md to find out more about calendar integration
# google:
# api_key: 'your-restricted-google-api-key'
# sources:
# holidays:
# id: 'de.german#holiday@group.v.calendar.google.com'
# color: '#ccc'
# --------------------------------------------------------------------------------
# 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: ~
# fallback color for all widgets that don't have a dedicated color
# possible options: blue, black, purple, yellow, red, green
box_color: 'green'
# --------------------------------------------------------------------------------
# Dashboard widget sections, please see documentation at var/docs/dashboard.md
dashboard:
user_duration:
title: dashboard.you
order: 10
permission: ROLE_USER
widgets: [userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates:
title: ~
order: 20
permission: ROLE_USER
widgets: [userAmountToday, userAmountWeek, userAmountMonth, userAmountYear]
duration:
title: dashboard.all
order: 30
permission: ROLE_TEAMLEAD
widgets: [durationToday, durationWeek, durationMonth, durationYear]
active_users:
title: ~
order: 40
permission: ROLE_TEAMLEAD
widgets: [activeUsersToday, activeUsersWeek, activeUsersMonth, activeUsersYear]
rates:
title: ~
order: 50
permission: ROLE_ADMIN
widgets: [amountToday, amountWeek, amountMonth, amountYear]
# --------------------------------------------------------------------------------
# All available widgets, please see documentation at var/docs/dashboard.md
widgets:
userDurationToday: { title: stats.durationToday, query: duration, user: true, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
userDurationWeek: { title: stats.durationWeek, query: duration, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
userDurationMonth: { title: stats.durationMonth, query: duration, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
userDurationYear: { title: stats.durationYear, query: duration, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
userDurationTotal: { title: stats.durationTotal, query: duration, user: true, icon: duration, color: red }
userAmountToday: { title: stats.amountToday, query: rate, user: true, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
userAmountWeek: { title: stats.amountWeek, query: rate, user: true, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
userAmountMonth: { title: stats.amountMonth, query: rate, user: true, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
userAmountYear: { title: stats.amountYear, query: rate, user: true, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
userAmountTotal: { title: stats.amountTotal, query: rate, user: true, icon: money, color: red }
durationToday: { title: stats.durationToday, query: duration, begin: '00:00:00', end: '23:59:59', icon: duration, color: green }
durationWeek: { title: stats.durationWeek, query: duration, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: duration, color: blue }
durationMonth: { title: stats.durationMonth, query: duration, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: duration, color: purple }
durationYear: { title: stats.durationYear, query: duration, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: duration, color: yellow }
durationTotal: { title: stats.durationTotal, query: duration, icon: duration, color: red }
amountToday: { title: stats.amountToday, query: rate, begin: '00:00:00', end: '23:59:59', icon: money, color: green }
amountWeek: { title: stats.amountWeek, query: rate, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: money, color: blue }
amountMonth: { title: stats.amountMonth, query: rate, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: money, color: purple }
amountYear: { title: stats.amountYear, query: rate, begin: '01 january this year 00:00:00', end: '31 december this year 23:59:59', icon: money, color: yellow }
amountTotal: { title: stats.amountTotal, query: rate, icon: money, color: red }
activeUsersToday: { title: stats.userActiveToday, query: users, begin: '00:00:00', end: '23:59:59', icon: user, color: green }
activeUsersWeek: { title: stats.userActiveWeek, query: users, begin: 'monday this week 00:00:00', end: 'sunday this week 23:59:59', icon: user, color: blue }
activeUsersMonth: { title: stats.userActiveMonth, query: users, begin: 'first day of this month 00:00:00', end: 'last day of this month 23:59:59', icon: user, color: purple }
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

@@ -0,0 +1,46 @@
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}}'
schemes: [http, https]
info:
title: Kimai 2 - API Docs
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
description: 'Value: {Username}'
name: X-AUTH-USER
in: header
X-AUTH-TOKEN:
type: apiKey
description: 'Value: {API Token}'
name: X-AUTH-TOKEN
in: header
security:
- X-AUTH-USER: []
- X-AUTH-TOKEN: []

View File

@@ -0,0 +1,6 @@
jms_serializer:
visitors:
json:
options:
- JSON_UNESCAPED_SLASHES
- JSON_PRESERVE_ZERO_FRACTION

View File

@@ -2,7 +2,6 @@ security:
encoders:
App\Entity\User: bcrypt
# https://symfony.com/doc/current/book/security.html#where-do-users-come-from-user-providers
providers:
fos_userbundle:
id: fos_user.user_provider.username_email
@@ -13,39 +12,28 @@ security:
security: false
secured_area:
# this firewall applies to all URLs
pattern: ^/
# make sure only allowed users have access to their time-tracking
user_checker: App\Security\UserChecker
anonymous: true
stateless: false
# but the firewall does not require login on every page
# denying access is done in access_control or in your controllers
anonymous: ~
guard:
authenticators:
- App\Security\TokenAuthenticator
remember_me:
secret: '%kernel.secret%'
lifetime: 604800
path: /
# This allows the user to login by submitting a username and password
# Reference: http://symfony.com/doc/current/cookbook/security/form_login_setup.html
form_login:
# fos user bundle handles the form login
provider: fos_userbundle
# The route name that the login form submits to
check_path: fos_user_security_check
# The name of the route where the login form lives
# When the user tries to access a protected page, they are redirected here
login_path: fos_user_security_login
# Secure the login form against CSRF
# Reference: http://symfony.com/doc/current/cookbook/security/csrf_in_login_form.html
csrf_token_generator: security.csrf.token_manager
logout:
# The route name the user can go to in order to logout
path: fos_user_security_logout
# The name of the route to redirect to after logging out
target: homepage
role_hierarchy:
@@ -59,5 +47,5 @@ security:
- { path: '^/(%app_locales%)/login', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/register', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/resetting', role: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: '^/(%app_locales%)/admin', roles: ROLE_ADMIN }
- { path: '^/(%app_locales%)/', roles: ROLE_USER }
- { path: '^/api', roles: IS_AUTHENTICATED_REMEMBERED }

View File

@@ -0,0 +1,3 @@
sensio_framework_extra:
router:
annotations: false

View File

@@ -2,3 +2,5 @@ twig:
#paths: ['%kernel.project_dir%/templates']
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes:
- 'form/kimai-theme.html.twig'

View File

@@ -4,22 +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
fos_user_security:
prefix: /{_locale}
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
fos_user_registration:
prefix: /{_locale}/register
resource: "@FOSUserBundle/Resources/config/routing/registration.xml"
fos_user_resetting:
prefix: /{_locale}/resetting
resource: "@FOSUserBundle/Resources/config/routing/resetting.xml"

View File

@@ -5,4 +5,9 @@ controllers:
requirements:
_locale: '%app_locales%'
defaults:
_locale: '%locale%'
_locale: '%locale%'
app.api:
resource: '../src/API/'
type: rest
prefix: /api

View File

@@ -0,0 +1,12 @@
# Expose security related features like login and logout
fos_user_security:
prefix: /{_locale}
resource: "@FOSUserBundle/Resources/config/routing/security.xml"
# The features "user registration" and "password-reset" are enabled by default.
#
# You can disable them by setting the config keys in file "config/kimai.yaml":
# - kimai.user.registration: false
# - kimai.user.password_reset: false
#
# The routes for these functions are added dynamically in src/Kernel.php

View File

@@ -0,0 +1,11 @@
# Expose the Kimai API documentation as HTML
app.swagger_ui:
path: /api/doc
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger_ui }
# Expose the Kimai API documentation as swagger compliant JSON
app.swagger:
path: /api/doc.json
methods: GET
defaults: { _controller: nelmio_api_doc.controller.swagger }

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

@@ -0,0 +1,32 @@
App\Entity\Activity:
exclusion_policy: All
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:
getProject:
serialized_name: project
exp: "object.getProject() === null ? null : object.getProject().getId()"
type: integer
groups: [Default]

View File

@@ -0,0 +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

@@ -0,0 +1,38 @@
App\Entity\Project:
exclusion_policy: All
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:
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

@@ -0,0 +1,15 @@
App\Entity\User:
exclusion_policy: All
properties:
id:
include: true
groups: [Default]
alias:
include: true
groups: [Default]
title:
include: true
groups: [Entity]
avatar:
include: true
groups: [Entity]

View File

@@ -0,0 +1,32 @@
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:
exclude: true
usernameCanonical:
exclude: true
password:
exclude: true
plainPassword:
exclude: true
registeredAt:
exclude: true
lastLogin:
exclude: true
confirmationToken:
exclude: true
passwordRequestedAt:
exclude: true
salt:
exclude: true

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|ru
app_locales: en|de|it|fr|es|ru|ar|hu|pt_BR
services:
# default configuration for services in *this* file
@@ -14,7 +14,7 @@ services:
# The best practice is to be explicit about your dependencies anyway.
bind:
$projectDirectory: "%kernel.project_dir%"
$durationOnly: "%kimai.timesheet.duration_only%"
$hardLimit: "%kimai.timesheet.active_entries.hard_limit%"
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
@@ -32,17 +32,29 @@ services:
# APPLICATION CORE
# ================================================================================
App\Invoice\ServiceInvoice:
class: App\Invoice\ServiceInvoice
arguments: ['%kimai.invoice%']
# a route listener, that injects the default locale into the route
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:
$config: "%kimai.calendar%"
App\Repository\WidgetRepository:
arguments:
$widgets: "%kimai.widgets%"
App\Controller\DashboardController:
arguments:
$dashboard: "%kimai.dashboard%"
App\Twig\MarkdownExtension:
arguments:
$timesheetAsMarkdown: "%kimai.timesheet.markdown%"
App\Controller\Admin\CustomerController:
arguments:
$defaults: "%kimai.defaults%"
# ================================================================================
# DATABASE
# ================================================================================
@@ -66,24 +78,36 @@ 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 }
App\Form\TimesheetEditForm:
arguments:
$durationOnly: "%kimai.timesheet.duration_only%"
App\Form\Extension\SelectWithApiDataExtension:
tags:
- { name: form.type_extension, extended_type: Symfony\Bridge\Doctrine\Form\Type\EntityType }
# ================================================================================
# THEME
# ================================================================================
App\Twig\Extensions:
arguments:
$locales: "%app_locales%"
$languages: "%kimai.languages%"
App\Twig\DateExtensions:
arguments:
@@ -101,3 +125,49 @@ services:
App\Timesheet\Calculator\RateCalculator:
arguments: ["%kimai.timesheet.rates%"]
# ================================================================================
# SECURITY & VOTER
# ================================================================================
App\Security\RolePermissionManager:
arguments: ["%kimai.permissions%"]
# ================================================================================
# REPOSITORIES
# ================================================================================
App\Repository\TimesheetRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Timesheet']
App\Repository\UserRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\User']
App\Repository\ActivityRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Activity']
App\Repository\ProjectRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Project']
App\Repository\CustomerRepository:
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

@@ -2,8 +2,4 @@ services:
_defaults:
public: true
# If you need to access services in a test, create an alias
# and then fetch that alias from the container. As a convention,
# aliases are prefixed with test. For example:
#
# test.App\Service\MyService: '@App\Service\MyService'
test.PasswordEncoder: "@security.encoder_factory"

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

@@ -9,11 +9,12 @@
"devDependencies": {
"@fortawesome/fontawesome-free": "~5.1.1",
"@symfony/webpack-encore": "^0.20.1",
"admin-lte": "~2.3.11",
"admin-lte": "~2.4.8",
"bootstrap-sass": "^3.3.7",
"bootstrap-select": "^1.13.1",
"chart.js": "1.1.*",
"chart.js": "~2.7.2",
"daterangepicker": "^3.0.3",
"fullcalendar": "^3.9.0",
"icheck": "^1.0.2",
"jquery": "^3.3.1",
"jquery-slimscroll": "^1.3.8",

View File

@@ -17,6 +17,8 @@
<env name="DATABASE_PREFIX" value="kimai2_" force="true"/>
<!-- define your env variables for the test env here -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
<!-- ###+ doctrine/doctrine-bundle ### -->
<env name="DATABASE_URL" value="sqlite:///var/data/kimai_test.sqlite" force="true"/>
<!-- ###- doctrine/doctrine-bundle ### -->
@@ -37,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>

View File

@@ -3,7 +3,7 @@
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex app.php
DirectoryIndex index.php
<IfModule mod_rewrite.c>
RewriteEngine On
@@ -30,7 +30,7 @@ DirectoryIndex app.php
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} ^$
RewriteRule ^app\.php(/(.*)|$) %{ENV:BASE}/$2 [R=301,L]
RewriteRule ^index\.php(/(.*)|$) %{ENV:BASE}/$2 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
@@ -38,7 +38,7 @@ DirectoryIndex app.php
RewriteRule .? - [L]
# Rewrite all other queries to the front controller.
RewriteRule .? %{ENV:BASE}/app.php [L]
RewriteRule .? %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
@@ -46,7 +46,7 @@ DirectoryIndex app.php
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 302 ^/$ /app.php/
RedirectMatch 302 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

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.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,8 +1,7 @@
{
"build/app.js": "/build/app.js?df75851c817558ca8803",
"build/app.css": "/build/app.css?b1944e8d2023603075ed8d913c21a8e8",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/images/boxed-bg.jpg": "/build/images/boxed-bg.jpg?7799dece",
"build/app.js": "/build/app.js?5b6a5a9160ea779a403e",
"build/app.css": "/build/app.css?fc142c29c80f5dcdbc4c745af16509f6",
"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",
@@ -14,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@2x.png": "/build/images/blue@2x.png?2694acfd",
"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",
@@ -24,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

@@ -0,0 +1,127 @@
<?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\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 Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Activity")
*
* @Security("is_granted('ROLE_USER')")
*/
class ActivityController extends BaseApiController
{
/**
* @var ActivityRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param ActivityRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, ActivityRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* 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")
* @Rest\QueryParam(name="globalsFirst", requirements="false", strict=true, nullable=true, description="Pass 'false' as string if you don't want the global activities to be listed first")
* @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|name|project", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name', 'project')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new ActivityQuery();
$query->setOrderGlobalsFirst(true)
->setResultType(ActivityQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
if (null !== ($globals = $paramFetcher->get('globals'))) {
$query->setGlobalsOnly(true);
}
if ('false' === ($globalsFirst = $paramFetcher->get('globalsFirst'))) {
$query->setOrderGlobalsFirst(false);
}
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="#/definitions/ActivityEntity"),
* )
*
* @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', '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

@@ -0,0 +1,112 @@
<?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\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 Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Customer")
*
* @Security("is_granted('ROLE_USER')")
*/
class CustomerController extends BaseApiController
{
/**
* @var CustomerRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param CustomerRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, CustomerRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* 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)")
* @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|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')")
*
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new CustomerQuery();
$query
->setResultType(CustomerQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
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="#/definitions/CustomerEntity"),
* )
*
* @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', 'Customer']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,72 @@
<?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\Constants;
use FOS\RestBundle\Controller\Annotations as Rest;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Swagger\Annotations as SWG;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class HealthcheckController extends Controller
{
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
*/
public function __construct(ViewHandlerInterface $viewHandler)
{
$this->viewHandler = $viewHandler;
}
/**
* @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")
*/
public function pingAction()
{
$view = new View(['message' => 'pong'], 200);
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

@@ -0,0 +1,22 @@
<?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\Component\HttpKernel\Exception\NotFoundHttpException;
class NotFoundException extends NotFoundHttpException
{
public function __construct(string $message = 'Not found', \Exception $previous = null, int $code = 0, array $headers = [])
{
parent::__construct($message, $previous, $code, $headers);
}
}

View File

@@ -0,0 +1,118 @@
<?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\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 Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Project")
*
* @Security("is_granted('ROLE_USER')")
*/
class ProjectController extends BaseApiController
{
/**
* @var ProjectRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param ProjectRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, ProjectRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* 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)")
* @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|name", strict=true, nullable=true, description="The field by which results will be ordered (allowed values: 'id', 'name')")
*
* @param ParamFetcherInterface $paramFetcher
* @return Response
*/
public function cgetAction(ParamFetcherInterface $paramFetcher)
{
$query = new ProjectQuery();
$query
->setResultType(ProjectQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('name')
;
if (null !== ($order = $paramFetcher->get('order'))) {
$query->setOrder($order);
}
if (null !== ($orderBy = $paramFetcher->get('orderBy'))) {
$query->setOrderBy($orderBy);
}
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="#/definitions/ProjectEntity"),
* )
*
* @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', 'Project']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -0,0 +1,216 @@
<?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")
*/
class TimesheetController extends BaseApiController
{
/**
* @var TimesheetRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @var int
*/
protected $hardLimit;
/**
* @param ViewHandlerInterface $viewHandler
* @param TimesheetRepository $repository
* @param int $hardLimit
*/
public function __construct(ViewHandlerInterface $viewHandler, TimesheetRepository $repository, int $hardLimit)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->hardLimit = $hardLimit;
}
/**
* @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')")
*
* @Security("is_granted('view_own_timesheet')")
*
* @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")
* )
*
* @Security("is_granted('view_own_timesheet')")
*
* @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"),
* )
* )
*
* @Security("is_granted('create_own_timesheet')")
*
* @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,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
]);
$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);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$this->repository->stopActiveEntries(
$timesheet->getUser(),
$this->hardLimit
);
$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

@@ -0,0 +1,91 @@
<?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\User;
use App\Repository\UserRepository;
use FOS\RestBundle\Controller\Annotations\RouteResource;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Swagger\Annotations as SWG;
use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("User")
*/
class UserController extends BaseApiController
{
/**
* @var UserRepository
*/
protected $repository;
/**
* @var ViewHandlerInterface
*/
protected $viewHandler;
/**
* @param ViewHandlerInterface $viewHandler
* @param UserRepository $repository
*/
public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
}
/**
* @SWG\Response(
* response=200,
* description="Returns the collection of all registered users",
* @SWG\Schema(ref="#/definitions/UserCollection"),
* )
*
* @Security("is_granted('view_user')")
*
* @return Response
*/
public function cgetAction()
{
$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="#/definitions/UserEntity"),
* )
*
* @Security("is_granted('view_user')")
*
* @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', 'User']);
return $this->viewHandler->handle($view);
}
}

66
src/Calendar/Config.php Normal file
View File

@@ -0,0 +1,66 @@
<?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\Calendar;
class Config
{
/**
* @var array
*/
protected $config = [];
/**
* @param array $config
*/
public function __construct(array $config)
{
$this->config = $config;
}
/**
* @return array
*/
public function getBusinessDays()
{
return $this->config['businessHours']['days'];
}
/**
* @return string
*/
public function getBusinessTimeBegin()
{
return $this->config['businessHours']['begin'];
}
/**
* @return string
*/
public function getBusinessTimeEnd()
{
return $this->config['businessHours']['end'];
}
/**
* @return int
*/
public function getDayLimit()
{
return $this->config['day_limit'];
}
/**
* @return bool
*/
public function isShowWeekNumbers()
{
return $this->config['week_numbers'];
}
}

48
src/Calendar/Google.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Calendar;
class Google
{
/**
* @var Source[]
*/
protected $sources = [];
/**
* @var string
*/
protected $apiKey = null;
/**
* @param string $apiKey
* @param Source[] $sources
*/
public function __construct($apiKey, $sources = [])
{
$this->apiKey = $apiKey;
$this->sources = $sources;
}
/**
* @return Source[]
*/
public function getSources()
{
return $this->sources;
}
/**
* @return string
*/
public function getApiKey()
{
return $this->apiKey;
}
}

59
src/Calendar/Service.php Normal file
View File

@@ -0,0 +1,59 @@
<?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\Calendar;
class Service
{
/**
* @var array
*/
protected $config;
/**
* Service constructor.
* @param array $config
*/
public function __construct(array $config)
{
$this->config = $config;
}
/**
* @return Config
*/
public function getConfig()
{
return new Config($this->config);
}
/**
* @return Google
*/
public function getGoogle()
{
$apiKey = $this->config['google']['api_key'] ?? null;
$sources = [];
if (isset($this->config['google']['sources'])) {
foreach ($this->config['google']['sources'] as $name => $config) {
$source = new Source();
$source
->setColor($config['color'])
->setUri($config['id'])
->setId($name)
;
$sources[] = $source;
}
}
return new Google($apiKey, $sources);
}
}

83
src/Calendar/Source.php Normal file
View File

@@ -0,0 +1,83 @@
<?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\Calendar;
class Source
{
/**
* @var string
*/
protected $id;
/**
* @var string
*/
protected $uri;
/**
* @var string
*/
protected $color;
/**
* @return string
*/
public function getId(): ?string
{
return $this->id;
}
/**
* @param string $id
* @return Source
*/
public function setId(string $id)
{
$this->id = $id;
return $this;
}
/**
* @return string
*/
public function getUri(): ?string
{
return $this->uri;
}
/**
* @param string $uri
* @return Source
*/
public function setUri(string $uri)
{
$this->uri = $uri;
return $this;
}
/**
* @return string
*/
public function getColor(): ?string
{
return $this->color;
}
/**
* @param string $color
* @return Source
*/
public function setColor(string $color)
{
$this->color = $color;
return $this;
}
}

View File

@@ -0,0 +1,268 @@
<?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\Calendar;
use App\Entity\Timesheet;
class TimesheetEntity
{
/**
* @var int
*/
protected $id;
/**
* @var \DateTime
*/
protected $start;
/**
* @var \DateTime|null
*/
protected $end;
/**
* @var string
*/
protected $title;
/**
* @var string
*/
protected $description;
/**
* @var string
*/
protected $customer;
/**
* @var string
*/
protected $project;
/**
* @var string
*/
protected $activity;
/**
* @var string|null
*/
protected $borderColor;
/**
* @var string|null
*/
protected $backgroundColor;
/**
* @param Timesheet $entry
*/
public function __construct(Timesheet $entry)
{
$this->id = $entry->getId();
$this->start = $entry->getBegin();
$this->title = $entry->getActivity()->getName();
$this->description = $entry->getDescription();
$this->customer = $entry->getProject()->getCustomer()->getName();
$this->project = $entry->getProject()->getName();
$this->activity = $entry->getActivity()->getName();
if (null === $entry->getEnd()) {
// TODO move these colors to the controller
$this->borderColor = '367fa9'; //'#f39c12';
$this->backgroundColor = '#3c8dbc'; //'#f39c12';
} else {
$this->end = $entry->getEnd();
}
}
/**
* @return int
*/
public function getId(): int
{
return $this->id;
}
/**
* @param int $id
* @return TimesheetEntity
*/
public function setId(int $id)
{
$this->id = $id;
return $this;
}
/**
* @return \DateTime
*/
public function getStart(): \DateTime
{
return $this->start;
}
/**
* @param \DateTime $start
* @return TimesheetEntity
*/
public function setStart(\DateTime $start)
{
$this->start = $start;
return $this;
}
/**
* @return \DateTime|null
*/
public function getEnd(): ?\DateTime
{
return $this->end;
}
/**
* @param \DateTime|null $end
* @return TimesheetEntity
*/
public function setEnd(?\DateTime $end)
{
$this->end = $end;
return $this;
}
/**
* @return string
*/
public function getTitle(): string
{
return $this->title;
}
/**
* @param string $title
* @return TimesheetEntity
*/
public function setTitle(string $title)
{
$this->title = $title;
return $this;
}
/**
* @return string
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* @param string $description
* @return TimesheetEntity
*/
public function setDescription(string $description)
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getCustomer(): string
{
return $this->customer;
}
/**
* @param string $customer
* @return TimesheetEntity
*/
public function setCustomer(string $customer)
{
$this->customer = $customer;
return $this;
}
/**
* @return string
*/
public function getProject(): string
{
return $this->project;
}
/**
* @param string $project
* @return TimesheetEntity
*/
public function setProject(string $project)
{
$this->project = $project;
return $this;
}
/**
* @return string
*/
public function getActivity(): string
{
return $this->activity;
}
/**
* @param string $activity
* @return TimesheetEntity
*/
public function setActivity(string $activity)
{
$this->activity = $activity;
return $this;
}
/**
* @return null|string
*/
public function getBorderColor(): ?string
{
return $this->borderColor;
}
/**
* @param null|string $borderColor
* @return TimesheetEntity
*/
public function setBorderColor(?string $borderColor)
{
$this->borderColor = $borderColor;
return $this;
}
/**
* @return null|string
*/
public function getBackgroundColor(): ?string
{
return $this->backgroundColor;
}
/**
* @param null|string $backgroundColor
* @return TimesheetEntity
*/
public function setBackgroundColor(?string $backgroundColor)
{
$this->backgroundColor = $backgroundColor;
return $this;
}
}

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 ...');
@@ -230,7 +247,7 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported users: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import users: ' . $ex->getMessage());
$io->error('Failed to import users: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
@@ -240,45 +257,41 @@ class KimaiImporterCommand extends Command
$allImports += $counter;
$io->success('Imported customers: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import customers: ' . $ex->getMessage());
$io->error('Failed to import customers: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
try {
$counter = $this->importProjects($io, $projects);
$counter = $this->importProjects($io, $projects, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported projects: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import projects: ' . $ex->getMessage());
$io->error('Failed to import projects: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
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) {
$io->error('Failed to import activities: ' . $ex->getMessage());
$io->error('Failed to import activities: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
return;
}
try {
$counter = $this->importTimesheetRecords($io, $records);
$counter = $this->importTimesheetRecords($io, $records, $fixedRates, $rates);
$allImports += $counter;
$io->success('Imported timesheet records: ' . $counter);
} catch (\Exception $ex) {
$io->error('Failed to import timesheet records: ' . $ex->getMessage());
$io->error('Failed to import timesheet records: ' . $ex->getMessage() . PHP_EOL . $ex->getTraceAsString());
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,35 @@ 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"';
$optionColumn = $this->connection->quoteIdentifier('option');
$qb = $this->connection->createQueryBuilder();
$version = $this->getImportConnection()->query($versionQuery)->fetchColumn();
$revision = $this->getImportConnection()->query($revisionQuery)->fetchColumn();
$version = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where($qb->expr()->eq($optionColumn, ':option'))
->setParameter('option', 'version')
->execute()
->fetchColumn();
$revision = $this->connection->createQueryBuilder()
->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where($qb->expr()->eq($optionColumn, ':option'))
->setParameter('option', 'revision')
->execute()
->fetchColumn();
if (1 == version_compare($requiredVersion, $version)) {
$io->error(
@@ -359,26 +385,23 @@ class KimaiImporterCommand extends Command
protected function bytesHumanReadable($size)
{
$unit = ['b', 'kB', 'MB', 'GB'];
$i = floor(log($size, 1024));
$a = (int) $i;
return @round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . ' ' . $unit[$i];
return @round($size / pow(1024, $i), 2) . ' ' . $unit[$a];
}
/**
* @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();
}
/**
@@ -401,12 +424,8 @@ class KimaiImporterCommand extends Command
if ($errors->count() > 0) {
/** @var \Symfony\Component\Validator\ConstraintViolation $error */
foreach ($errors as $error) {
$value = $error->getInvalidValue();
$io->error(
$error->getPropertyPath()
. ' (' . (is_array($value) ? implode(',', $value) : $value) . ')'
. "\n "
. $error->getMessage()
(string) $error
);
}
@@ -508,8 +527,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 +557,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 +607,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 +634,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 +695,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 +738,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 +749,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 +786,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 +830,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 +860,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 +887,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,11 +935,11 @@ class KimaiImporterCommand extends Command
->setEnd(new \DateTime('@' . $oldRecord['end']))
->setDuration($duration)
->setActivity($activity)
->setRate($rate)
->setProject($project)
;
if (!$this->validateImport($io, $timesheet)) {
throw new \Exception('Failed to validate timesheet record: ' . $timesheet->getId());
throw new \Exception('Failed to validate timesheet record: ' . $oldRecord['timeEntryID']);
}
try {
@@ -841,17 +950,23 @@ class KimaiImporterCommand extends Command
}
++$counter;
} catch (\Exception $ex) {
$io->error('Failed to create timesheet record: ' . $timesheet->getId());
$io->error('Reason: ' . $ex->getMessage());
$io->error('Failed to create timesheet record: ' . $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.7';
/**
* 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.
*
@@ -58,52 +42,51 @@ abstract class AbstractController extends Controller
*/
protected function flashSuccess($translationKey, $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_SUCCESS, $translationKey);
$this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
}
/**
* Adds a "warning" flash message to the stack.
*
* @param $translationKey
* @param string $translationKey
* @param array $parameter
*/
protected function flashWarning($translationKey, $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_WARNING, $translationKey);
$this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
}
/**
* Adds a "error" flash message to the stack.
*
* @param $translationKey
* @param string $translationKey
* @param array $parameter
*/
protected function flashError($translationKey, $parameter = [])
{
$this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter);
}
/**
* Adds a fully translated (both $message and all keys in $parameter) flash message to the stack.
*
* @param string $type
* @param string $message
* @param array $parameter
*/
protected function addFlashTranslated(string $type, string $message, array $parameter = [])
{
if (!empty($parameter)) {
$translationKey = $this->getTranslator()->trans(
$translationKey,
foreach ($parameter as $key => $value) {
$parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
}
$message = $this->getTranslator()->trans(
$message,
$parameter,
self::DOMAIN_FLASH
);
}
$this->addFlash(self::FLASH_ERROR, $translationKey);
$this->addFlash($type, $message);
}
}

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,149 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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',
'date.timezone',
];
$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,20 +13,21 @@ 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\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage activities in the admin part of the site.
*
* @Route("/admin/activity")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Route(path="/admin/activity")
* @Security("is_granted('view_activity')")
*/
class ActivityController extends AbstractController
{
@@ -39,10 +40,14 @@ class ActivityController extends AbstractController
}
/**
* @Route("/", defaults={"page": 1}, name="admin_activity")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated")
* @Method("GET")
* @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"})
* @Cache(smaxage="10")
* @Security("is_granted('view_activity')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -63,13 +68,14 @@ class ActivityController extends AbstractController
return $this->render('admin/activity.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route("/create", name="admin_activity_create")
* @Method({"GET", "POST"})
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
* @Security("is_granted('create_activity')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
@@ -80,8 +86,7 @@ class ActivityController extends AbstractController
}
/**
* @Route("/{id}/edit", name="admin_activity_edit")
* @Method({"GET", "POST"})
* @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', activity)")
*
* @param Activity $activity
@@ -94,10 +99,7 @@ class ActivityController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route("/{id}/delete", name="admin_activity_delete")
* @Method({"GET", "POST"})
* @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', activity)")
*
* @param Activity $activity
@@ -109,6 +111,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();
@@ -116,13 +134,14 @@ class ActivityController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($activity);
$entityManager->flush();
try {
$this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
$this->flashSuccess('action.deleted_successfully');
return $this->redirectToRoute('admin_activity', ['id' => $activity->getId()]);
return $this->redirectToRoute('admin_activity');
}
return $this->render(
@@ -151,7 +170,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();
@@ -160,7 +179,7 @@ class ActivityController extends AbstractController
$editForm->get('create_more')->setData(true);
$activity = $newActivity;
} else {
return $this->redirectToRoute('admin_activity', ['id' => $activity->getId()]);
return $this->redirectToRoute('admin_activity');
}
}

View File

@@ -13,22 +13,36 @@ 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\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage activities in the admin part of the site.
*
* @Route("/admin/customer")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Route(path="/admin/customer")
* @Security("is_granted('view_customer')")
*/
class CustomerController extends AbstractController
{
/**
* @var array
*/
private $defaults;
/**
* @param array $defaults
*/
public function __construct(array $defaults)
{
$this->defaults = $defaults;
}
/**
* @return \App\Repository\CustomerRepository
*/
@@ -38,9 +52,13 @@ class CustomerController extends AbstractController
}
/**
* @Route("/", defaults={"page": 1}, name="admin_customer")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated")
* @Method("GET")
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -60,29 +78,90 @@ class CustomerController extends AbstractController
return $this->render('admin/customer.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route("/create", name="admin_customer_create")
* @Method({"GET", "POST"})
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
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);
}
/**
* @Route("/{id}/edit", name="admin_customer_edit")
* @Method({"GET", "POST"})
* @Route(path="/{id}/edit", name="admin_customer_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Customer $customer, Request $request)
{
return $this->renderCustomerForm($customer, $request);
}
/**
* @Route(path="/{id}/delete", name="admin_customer_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function deleteAction(Customer $customer, Request $request)
{
$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();
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
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');
}
return $this->render('admin/customer_delete.html.twig', [
'customer' => $customer,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @param Customer $customer
* @param Request $request
@@ -99,9 +178,9 @@ class CustomerController extends AbstractController
$entityManager->persist($customer);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer', ['id' => $customer->getId()]);
return $this->redirectToRoute('admin_customer');
}
return $this->render('admin/customer_edit.html.twig', [
@@ -110,45 +189,6 @@ class CustomerController extends AbstractController
]);
}
/**
* The route to delete an existing entry.
*
* @Route("/{id}/delete", name="admin_customer_delete")
* @Method({"GET", "POST"})
* @Security("is_granted('delete', customer)")
*
* @param Customer $customer
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function deleteAction(Customer $customer, Request $request)
{
$stats = $this->getRepository()->getCustomerStatistics($customer);
$deleteForm = $this->createFormBuilder()
->setAction($this->generateUrl('admin_customer_delete', ['id' => $customer->getId()]))
->setMethod('POST')
->getForm();
$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');
return $this->redirectToRoute('admin_customer', ['id' => $customer->getId()]);
}
return $this->render('admin/customer_delete.html.twig', [
'customer' => $customer,
'stats' => $stats,
'form' => $deleteForm->createView(),
]);
}
/**
* @param CustomerQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -14,20 +14,21 @@ 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\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage projects in the admin part of the site.
*
* @Route("/admin/project")
* @Security("is_granted('ROLE_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Route(path="/admin/project")
* @Security("is_granted('view_project')")
*/
class ProjectController extends AbstractController
{
@@ -40,10 +41,14 @@ class ProjectController extends AbstractController
}
/**
* @Route("/", defaults={"page": 1}, name="admin_project")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated")
* @Method("GET")
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Cache(smaxage="10")
* @Security("is_granted('view_project')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -64,13 +69,17 @@ class ProjectController extends AbstractController
return $this->render('admin/project.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route("/create", name="admin_project_create")
* @Method({"GET", "POST"})
* @Route(path="/create", name="admin_project_create", methods={"GET", "POST"})
* @Security("is_granted('create_project')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
@@ -78,9 +87,12 @@ class ProjectController extends AbstractController
}
/**
* @Route("/{id}/edit", name="admin_project_edit")
* @Method({"GET", "POST"})
* @Route(path="/{id}/edit", name="admin_project_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*
* @param Project $project
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Project $project, Request $request)
{
@@ -88,10 +100,7 @@ class ProjectController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route("/{id}/delete", name="admin_project_delete")
* @Method({"GET", "POST"})
* @Route(path="/{id}/delete", name="admin_project_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', project)")
*
* @param Project $project
@@ -103,6 +112,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();
@@ -110,13 +132,14 @@ class ProjectController extends AbstractController
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($project);
$entityManager->flush();
try {
$this->getRepository()->deleteProject($project, $deleteForm->get('project')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error');
}
$this->flashSuccess('action.deleted_successfully');
return $this->redirectToRoute('admin_project', ['id' => $project->getId()]);
return $this->redirectToRoute('admin_project');
}
return $this->render('admin/project_delete.html.twig', [
@@ -142,7 +165,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();
@@ -151,7 +174,7 @@ class ProjectController extends AbstractController
$editForm->get('create_more')->setData(true);
$project = $newProject;
} else {
return $this->redirectToRoute('admin_project', ['id' => $project->getId()]);
return $this->redirectToRoute('admin_project');
}
}

View File

@@ -16,37 +16,24 @@ use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetAdminToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used for manage timesheet entries in the admin part of the site.
*
* @Route("/team/timesheet")
* @Security("is_granted('ROLE_TEAMLEAD')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Route(path="/team/timesheet")
* @Security("is_granted('view_other_timesheet')")
*/
class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* TimesheetController constructor.
* @param bool $durationOnly
*/
public function __construct(bool $durationOnly)
{
$this->setDurationMode($durationOnly);
}
/**
* This route shows all users timesheet entries.
*
* @Route("/", defaults={"page": 1}, name="admin_timesheet")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated")
* @Method("GET")
* @Route(path="/", defaults={"page": 1}, name="admin_timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_timesheet_paginated", methods={"GET"})
* @Security("is_granted('view_other_timesheet')")
*
* @param $page
* @param Request $request
@@ -62,6 +49,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,17 +62,47 @@ class TimesheetController extends AbstractController
return $this->render('admin/timesheet.html.twig', [
'entries' => $entries,
'page' => $page,
'page' => $query->getPage(),
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* The route to stop a running entry.
* @Route(path="/export", name="admin_timesheet_export", methods={"GET"})
*
* @Route("/{id}/stop", name="admin_timesheet_stop")
* @Method({"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);
}
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/timesheet_export.html.twig', [
'entries' => $entries,
'query' => $query,
]);
}
/**
* @Route(path="/{id}/stop", name="admin_timesheet_stop", methods={"GET"})
* @Security("is_granted('stop', entry)")
*
* @param Timesheet $entry
@@ -91,10 +114,7 @@ class TimesheetController extends AbstractController
}
/**
* The route to edit an existing entry.
*
* @Route("/{id}/edit", name="admin_timesheet_edit")
* @Method({"GET", "POST"})
* @Route(path="/{id}/edit", name="admin_timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*
* @param Timesheet $entry
@@ -107,10 +127,8 @@ class TimesheetController extends AbstractController
}
/**
* The route to create a new entry by form.
*
* @Route("/create", name="admin_timesheet_create")
* @Method({"GET", "POST"})
* @Route(path="/create", name="admin_timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_other_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
@@ -121,10 +139,7 @@ class TimesheetController extends AbstractController
}
/**
* The route to delete an existing entry.
*
* @Route("/{id}/delete", name="admin_timesheet_delete")
* @Method({"GET", "POST"})
* @Route(path="/{id}/delete", defaults={"page": 1}, name="admin_timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry
@@ -138,9 +153,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')]);
@@ -148,33 +163,33 @@ class TimesheetController extends AbstractController
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getCreateForm(Timesheet $entry)
protected function getCreateForm(Timesheet $entry, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create'),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_user' => true
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true,
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getEditForm(Timesheet $entry, $page)
protected function getEditForm(Timesheet $entry, $page, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
'page' => $page,
]),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_user' => true
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_user' => true,
]);
}

View File

@@ -10,29 +10,54 @@
namespace App\Controller\Admin;
use App\Controller\AbstractController;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Toolbar\UserToolbarForm;
use App\Form\UserCreateType;
use App\Repository\Query\UserQuery;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* Controller used to manage users in the admin part of the site.
*
* @Route("/admin/user")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('IS_AUTHENTICATED_FULLY')")
* @Route(path="/admin/user")
* @Security("is_granted('view_user')")
*/
class UserController extends AbstractController
{
/**
* @Route("/", defaults={"page": 1}, name="admin_user")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated")
* @Method("GET")
* @var UserPasswordEncoderInterface
*/
protected $encoder;
/**
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)
{
$this->encoder = $encoder;
}
/**
* @return \App\Repository\UserRepository
*/
protected function getRepository()
{
return $this->getDoctrine()->getRepository(User::class);
}
/**
* @Route(path="/", defaults={"page": 1}, name="admin_user", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated", methods={"GET"})
* @Security("is_granted('view_user')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -47,30 +72,33 @@ class UserController extends AbstractController
}
/* @var $entries Pagerfanta */
$entries = $this->getDoctrine()->getRepository(User::class)->findByQuery($query);
$entries = $this->getRepository()->findByQuery($query);
return $this->render('admin/user.html.twig', [
'entries' => $entries,
'query' => $query,
'showFilter' => $form->isSubmitted(),
'toolbarForm' => $form->createView(),
]);
}
/**
* @Route("/create", name="admin_user_create")
* @Method({"GET", "POST"})
* @Security("is_granted('create', user)")
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
* @Security("is_granted('create_user')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
$user = new User();
$user->setEnabled(true);
$editForm = $this->createEditForm($user);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$password = $this->get('security.password_encoder')
->encodePassword($user, $user->getPlainPassword());
$password = $this->encoder->encodePassword($user, $user->getPlainPassword());
$user->setPassword($password);
$user->setEnabled(true);
$user->setRoles([User::DEFAULT_ROLE]);
@@ -79,7 +107,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()]);
@@ -99,6 +127,47 @@ class UserController extends AbstractController
);
}
/**
* @Route(path="/{id}/delete", name="admin_user_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', userToDelete)")
*
* @param User $userToDelete
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function deleteAction(User $userToDelete, Request $request)
{
// $userToDelete MUST not be called $user, as $user is always the current user!
$stats = $this->getDoctrine()->getRepository(Timesheet::class)->getUserStatistics($userToDelete);
$deleteForm = $this->createFormBuilder()
->setAction($this->generateUrl('admin_user_delete', ['id' => $userToDelete->getId()]))
->setMethod('POST')
->getForm();
$deleteForm->handleRequest($request);
if (0 == $stats->getRecordsTotal() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) {
$entityManager = $this->getDoctrine()->getManager();
$entityManager->remove($userToDelete);
$entityManager->flush();
$this->flashSuccess('action.delete.success');
return $this->redirectToRoute('admin_user');
}
return $this->render(
'admin/user_delete.html.twig',
[
'user' => $userToDelete,
'stats' => $stats,
'form' => $deleteForm->createView(),
]
);
}
/**
* @param UserQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -0,0 +1,97 @@
<?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\Calendar\Service;
use App\Calendar\TimesheetEntity;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to display calendars.
*
* @Route(path="/calendar")
* @Security("is_granted('ROLE_USER')")
*/
class CalendarController extends AbstractController
{
/**
* @var Service
*/
protected $calendar;
/**
* @param Service $calendar
*/
public function __construct(Service $calendar)
{
$this->calendar = $calendar;
}
/**
* @Route(path="/", name="calendar", methods={"GET"})
* @Cache(smaxage="10")
*/
public function userCalendar()
{
return $this->render('calendar/user.html.twig', [
'config' => $this->calendar->getConfig(),
'google' => $this->calendar->getGoogle()
]);
}
/**
* @Route(path="/user", name="calendar_entries", methods={"GET"})
* @Cache(smaxage="10")
*/
public function calendarEntries(Request $request)
{
$start = $request->get('start');
$end = $request->get('end');
$start = \DateTime::createFromFormat('Y-m-d', $start);
if ($start === false) {
$start = new \DateTime('first day of this month');
}
$start->setTime(0, 0, 0);
$end = \DateTime::createFromFormat('Y-m-d', $end);
if ($end === false) {
$end = clone $start;
$end = $end->modify('last day of this month');
}
$end->setTime(23, 59, 59);
$query = new TimesheetQuery();
$query
->setBegin($start)
->setUser($this->getUser())
->setState(TimesheetQuery::STATE_ALL)
->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER)
->setEnd($end)
;
$repository = $this->getDoctrine()->getRepository(Timesheet::class);
/* @var $entries Timesheet[] */
$entries = $repository->findByQuery($query)->getQuery()->execute();
$result = [];
foreach ($entries as $entry) {
$result[] = new TimesheetEntity($entry);
}
return $this->json($result);
}
}

View File

@@ -9,139 +9,99 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Query\TimesheetQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
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;
/**
* Dashboard controller for the admin area.
*
* @Route("/dashboard")
* @Route(path="/dashboard")
* @Security("is_granted('ROLE_USER')")
*/
class DashboardController extends Controller
class DashboardController extends AbstractController
{
/**
* @Route("/", defaults={}, name="dashboard")
* @Method("GET")
* @var EventDispatcherInterface
*/
protected $eventDispatcher;
/**
* @var WidgetRepository
*/
protected $repository;
/**
* @var array
*/
protected $dashboard;
/**
* @param EventDispatcherInterface $dispatcher
* @param WidgetRepository $repository
* @param array $dashboard
*/
public function __construct(EventDispatcherInterface $dispatcher, WidgetRepository $repository, array $dashboard)
{
$this->eventDispatcher = $dispatcher;
$this->repository = $repository;
$this->dashboard = $dashboard;
}
/**
* @Route(path="/", defaults={}, name="dashboard", methods={"GET"})
*/
public function indexAction()
{
$user = $this->getUser();
$event = new DashboardEvent($this->getUser());
$userStats = $this->getDoctrine()->getRepository(User::class)->getGlobalStatistics();
foreach ($this->dashboard as $widgetRow) {
if (empty($widgetRow['widgets'])) {
continue;
}
// FIXME move the other widgets to the Kimai, the inheritence is wrong as Kimai
// shouldn't know about Timesheets
if (!$this->isGranted($widgetRow['permission'])) {
continue;
}
$timesheetRepo = $this->getDoctrine()->getRepository(Timesheet::class);
$timesheetUserStats = $timesheetRepo->getUserStatistics($user);
$timesheetGlobalStats = $timesheetRepo->getGlobalStatistics();
$row = new DashboardSection($widgetRow['title'] ?? null);
$row
->setOrder($widgetRow['order'])
->setType($widgetRow['type'])
;
$activityStats = $this->getDoctrine()->getRepository(Activity::class)->getGlobalStatistics();
$projectStats = $this->getDoctrine()->getRepository(Project::class)->getGlobalStatistics();
$customerStats = $this->getDoctrine()->getRepository(Customer::class)->getGlobalStatistics();
foreach ($widgetRow['widgets'] as $widgetName) {
if (!$this->repository->has($widgetName)) {
throw new \Exception('Unknwon widget: ' . $widgetName);
}
$row->addWidget($this->repository->get($widgetName, $event->getUser()));
}
$event->addSection($row);
}
$this->eventDispatcher->dispatch(
DashboardEvent::DASHBOARD,
$event
);
$sections = $event->getSections();
uasort(
$sections,
function (DashboardSection $a, DashboardSection $b) {
if ($a->getOrder() == $b->getOrder()) {
return 0;
}
return ($a->getOrder() < $b->getOrder()) ? -1 : 1;
}
);
return $this->render('dashboard/index.html.twig', [
'dashboard_widgets' => $this->getWidgets(),
'timesheetGlobal' => $timesheetGlobalStats,
'timesheetUser' => $timesheetUserStats,
'activity' => $activityStats,
'project' => $projectStats,
'customer' => $customerStats,
'user' => $userStats,
'widget_rows' => $sections
]);
}
/**
* colors: blue / yellow / purple / green / black
* icons: bar-chart / line-chart / calendar / clock
*
* @return array
*/
protected function getWidgets()
{
// @codingStandardsIgnoreStart
$widgets = [
/*
[
'header' => 'dashboard.you',
'widgets' => [
"{{ widgets.info_box_progress('Bewilligte Stunden', 'Stunden zur Abrechnung bewilligt', 120, 10, 'star') }}",
"{{ widgets.info_box_progress('Umsatz / Monat', '70% Increase in 30 Days', 6830, 30, 'credit-card', 'black') }}",
"{{ widgets.info_box_progress('Stunden persönlich', 'Das ist noch nicht genug', 135, 60, 'hourglass') }}",
"{{ widgets.info_box_progress('Anzahl Benutzer', 'Mehr ist besser!', 5, 90, 'user') }}",
],
],
*/
[
'id' => 'profile.stats',
'header' => 'dashboard.you',
'widgets' => [
"{{ widgets.info_box_counter('stats.durationThisMonth', timesheetUser.durationThisMonth|duration(true), 'far fa-hourglass', 'green') }}",
//"{{ widgets.info_box_counter('stats.amountThisMonth', timesheetUser.amountThisMonth|money, 'money', 'blue') }}",
"{{ widgets.info_box_counter('stats.durationTotal', timesheetUser.durationTotal|duration(true), 'far fa-hourglass', 'red') }}",
//"{{ widgets.info_box_counter('stats.amountTotal', timesheetUser.amountTotal|money, 'money', 'yellow') }}",
],
],
];
if (!$this->isGranted('ROLE_TEAMLEAD', null)) {
return $widgets;
}
$widgets[] = [
'id' => 'alluser.stats',
'header' => 'dashboard.all',
'widgets' => [
"{{ widgets.info_box_counter('stats.durationThisMonth', timesheetGlobal.durationThisMonth|duration(true), 'far fa-hourglass', 'blue') }}",
"{{ widgets.info_box_counter('stats.durationTotal', timesheetGlobal.durationTotal|duration(true), 'far fa-hourglass', 'yellow') }}",
"{{ widgets.info_box_counter('stats.activeRecordings', timesheetGlobal.activeCurrently, 'far fa-hourglass', 'red', path('admin_timesheet', {'state': " . TimesheetQuery::STATE_RUNNING . '})) }}',
],
];
$widgets[] = [
'id' => 'user.stats',
'header' => '',
'widgets' => [
"{{ widgets.info_box_counter('stats.userTotal', user.totalAmount, 'user', 'red') }}",
"{{ widgets.info_box_counter('stats.userActiveThisMoth', timesheetGlobal.activeThisMonth, 'user', 'yellow') }}",
"{{ widgets.info_box_counter('stats.userActiveEver', timesheetGlobal.activeTotal, 'user', 'blue') }}",
],
];
if (!$this->isGranted('ROLE_ADMIN', null)) {
return $widgets;
}
$widgets[] = [
'id' => 'alluser.money_stats',
'header' => '',
'widgets' => [
"{{ widgets.info_box_counter('stats.amountThisMonth', timesheetGlobal.amountThisMonth|money, 'far fa-money-bill-alt', 'green') }}",
"{{ widgets.info_box_counter('stats.amountTotal', timesheetGlobal.amountTotal|money, 'far fa-money-bill-alt', 'red') }}",
],
];
$widgets[] = [
'id' => 'admin.stats',
'header' => 'dashboard.admin',
'widgets' => [
"{{ widgets.info_box_more('stats.userTotal', user.totalAmount, ' ', path('admin_user'), 'user') }}",
"{{ widgets.info_box_more('stats.customerTotal', customer.count, '', path('admin_customer'), 'customer', 'blue') }}",
"{{ widgets.info_box_more('stats.projectsTotal', project.count, '', path('admin_project'), 'project', 'yellow') }}",
"{{ widgets.info_box_more('stats.activitiesTotal', activity.count, '', path('admin_activity'), 'activity', 'purple') }}",
],
];
// @codingStandardsIgnoreEnd
return $widgets;
}
}

View File

@@ -10,18 +10,16 @@
namespace App\Controller;
use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
/**
* This controller can render the markdown documentation from /var/docs/
*
* @Route("/help")
* @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/';
@@ -41,9 +39,8 @@ class HelpController extends Controller
}
/**
* @Route("/", defaults={"chapter": "README"}, name="help")
* @Route("/{chapter}", requirements={"chapter": "[a-zA-Z]*"}, name="help_chapter")
* @Method("GET")
* @Route(path="/", defaults={"chapter": "README"}, name="help", methods={"GET"})
* @Route(path="/{chapter}", requirements={"chapter": "[a-zA-Z]*"}, name="help_chapter", methods={"GET"})
*
* @param string $chapter
* @return \Symfony\Component\HttpFoundation\Response
@@ -63,6 +60,8 @@ class HelpController extends Controller
$content = file_get_contents($chapterFile);
$content = preg_replace('/\[(.+)\]\(([\w\-\/]+)(\.md)(\#[\w\-]+){0,1}\)/i', '[$1]($2$4)', $content);
return $this->render('help/index.html.twig', [
'breadcrumb' => $breadcrumb,
'chapter' => $chapter,

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,20 +15,20 @@ 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;
use App\Repository\TimesheetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage invoices.
*
* @Route("/invoice")
* @Security("is_granted('ROLE_TEAMLEAD')")
* @Route(path="/invoice")
* @Security("is_granted('view_invoice') or is_granted('view_invoice_template')")
*/
class InvoiceController extends AbstractController
{
@@ -36,14 +36,25 @@ class InvoiceController extends AbstractController
* @var ServiceInvoice
*/
protected $service;
/**
* @var InvoiceTemplateRepository
*/
protected $invoiceRepository;
/**
* @var TimesheetRepository
*/
protected $timesheetRepository;
/**
* InvoiceController constructor.
* @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;
}
/**
@@ -56,6 +67,7 @@ class InvoiceController extends AbstractController
$end = new \DateTime('last day of this month');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setUser($this->getUser());
@@ -65,15 +77,8 @@ class InvoiceController extends AbstractController
}
/**
* @return \App\Repository\InvoiceTemplateRepository
*/
protected function getRepository()
{
return $this->getDoctrine()->getRepository(InvoiceTemplate::class);
}
/**
* @Route("/", name="invoice")
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
@@ -81,7 +86,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');
}
@@ -89,31 +94,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) {
@@ -128,29 +205,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("/template", defaults={"page": 1}, name="admin_invoice_template")
* @Route("/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated")
* @Method({"GET", "POST"})
*
* TODO permission
* @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"})
* @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,
@@ -159,11 +229,10 @@ class InvoiceController extends AbstractController
}
/**
* @Route("/{id}/edit", name="admin_invoice_template_edit")
* @Method({"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
@@ -174,22 +243,60 @@ class InvoiceController extends AbstractController
}
/**
* @Route("/create", name="admin_invoice_template_create")
* @Method({"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')]);
}
/**
@@ -204,13 +311,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', [
@@ -219,28 +327,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
@@ -249,7 +335,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

@@ -11,29 +11,58 @@ namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Event\PrepareUserEvent;
use App\Form\UserApiTokenType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Repository\TimesheetRepository;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\Form;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
* User profile controller
*
* @Route("/profile")
* @Security("is_granted('ROLE_USER')")
* @Route(path="/profile")
* @Security("is_granted('view_own_profile') or is_granted('view_other_profile')")
*/
class ProfileController extends AbstractController
{
/**
* @Route("/{username}", name="user_profile")
* @Method("GET")
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var UserPasswordEncoderInterface
*/
protected $encoder;
/**
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
}
/**
* @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)")
*/
public function indexAction(User $profile)
@@ -42,8 +71,7 @@ class ProfileController extends AbstractController
}
/**
* @Route("/{username}/edit", name="user_profile_edit")
* @Method({"GET", "POST"})
* @Route(path="/{username}/edit", name="user_profile_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', profile)")
*/
public function editAction(User $profile, Request $request)
@@ -56,17 +84,16 @@ 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);
}
/**
* @Route("/{username}/password", name="user_profile_password")
* @Method({"GET", "POST"})
* @Route(path="/{username}/password", name="user_profile_password", methods={"GET", "POST"})
* @Security("is_granted('password', profile)")
*/
public function passwordAction(User $profile, Request $request)
@@ -75,25 +102,48 @@ class ProfileController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $this->get('security.password_encoder')
->encodePassword($profile, $profile->getPlainPassword());
$password = $this->encoder->encodePassword($profile, $profile->getPlainPassword());
$profile->setPassword($password);
$entityManager = $this->getDoctrine()->getManager();
$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);
}
/**
* @Route("/{username}/roles", name="user_profile_roles")
* @Method({"GET", "POST"})
* @Route(path="/{username}/api-token", name="user_profile_api_token", methods={"GET", "POST"})
* @Security("is_granted('api-token', profile)")
*/
public function apiTokenAction(User $profile, Request $request)
{
$form = $this->createApiTokenForm($profile);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $this->encoder->encodePassword($profile, $profile->getPlainApiToken());
$profile->setApiToken($password);
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_api_token', ['username' => $profile->getUsername()]);
}
return $this->getProfileView($profile, 'api-token', null, null, null, null, $form);
}
/**
* @Route(path="/{username}/roles", name="user_profile_roles", methods={"GET", "POST"})
* @Security("is_granted('roles', profile)")
*/
public function rolesAction(User $profile, Request $request)
@@ -106,21 +156,24 @@ 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);
}
/**
* @Route("/{username}/prefs", name="user_profile_preferences")
* @Method({"GET", "POST"})
* @Route(path="/{username}/prefs", name="user_profile_preferences", methods={"GET", "POST"})
* @Security("is_granted('preferences', profile)")
*/
public function savePreferencesAction(User $profile, Request $request)
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
$original = [];
foreach ($profile->getPreferences() as $preference) {
$original[$preference->getName()] = $preference;
@@ -147,15 +200,22 @@ class ProfileController extends AbstractController
}
}
foreach ($preferences as $preference) {
$preference->setUser($profile);
$entityManager->persist($preference);
$entityManager->flush();
$profile->setPreferences($preferences);
$entityManager->persist($profile);
$entityManager->flush();
$this->flashSuccess('action.update.success');
// switch locale ONLY if updated profile is the current user
$locale = $request->getLocale();
if ($this->getUser()->getId() === $profile->getId()) {
$locale = $profile->getPreferenceValue('language', $locale);
}
$this->flashSuccess('action.updated_successfully');
return $this->redirectToRoute('user_profile', ['username' => $profile->getUsername()]);
return $this->redirectToRoute('user_profile_preferences', [
'_locale' => $locale,
'username' => $profile->getUsername()
]);
}
return $this->getProfileView($profile, 'preferences', null, null, null, $form);
@@ -168,6 +228,7 @@ class ProfileController extends AbstractController
* @param Form|null $pwdForm
* @param Form|null $rolesForm
* @param Form|null $prefsForm
* @param Form|null $apiTokenForm
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Doctrine\ORM\NonUniqueResultException
*/
@@ -177,7 +238,8 @@ class ProfileController extends AbstractController
Form $editForm = null,
Form $pwdForm = null,
Form $rolesForm = null,
Form $prefsForm = null
Form $prefsForm = null,
Form $apiTokenForm = null
) {
/* @var $timesheetRepo TimesheetRepository */
$timesheetRepo = $this->getDoctrine()->getRepository(Timesheet::class);
@@ -200,6 +262,10 @@ class ProfileController extends AbstractController
$pwdForm = $pwdForm ?: $this->createPasswordForm($user);
$viewVars['forms']['password'] = $pwdForm->createView();
}
if ($this->isGranted(UserVoter::API_TOKEN, $user)) {
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$viewVars['forms']['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted(UserVoter::ROLES, $user)) {
$rolesForm = $rolesForm ?: $this->createRolesForm($user);
$viewVars['forms']['roles'] = $rolesForm->createView();
@@ -218,6 +284,10 @@ class ProfileController extends AbstractController
*/
private function createPreferencesForm(User $user)
{
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
$this->dispatcher->dispatch(PrepareUserEvent::PREPARE, $event);
return $this->createForm(
UserPreferencesForm::class,
$user,
@@ -276,4 +346,21 @@ class ProfileController extends AbstractController
]
);
}
/**
* @param User $user
* @return \Symfony\Component\Form\FormInterface
*/
private function createApiTokenForm(User $user)
{
return $this->createForm(
UserApiTokenType::class,
$user,
[
'validation_groups' => ['apiTokenUpdate'],
'action' => $this->generateUrl('user_profile_api_token', ['username' => $user->getUsername()]),
'method' => 'POST'
]
);
}
}

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,42 +9,42 @@
namespace App\Controller;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\Query\TimesheetQuery;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
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 timesheet contents in the public part of the site.
* Controller used to manage timesheets.
*
* @Route("/timesheet")
* @Security("is_granted('ROLE_USER')")
* @Route(path="/timesheet")
* @Security("is_granted('view_own_timesheet')")
*/
class TimesheetController extends AbstractController
{
use TimesheetControllerTrait;
/**
* TimesheetController constructor.
* @param bool $durationOnly
* @param int $hardLimit
*/
public function __construct(bool $durationOnly)
public function __construct(int $hardLimit)
{
$this->setDurationMode($durationOnly);
$this->setHardLimit($hardLimit);
}
/**
* @Route("/", defaults={"page": 1}, name="timesheet")
* @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated")
* @Method("GET")
* @Cache(smaxage="10")
* @Route(path="/", defaults={"page": 1}, name="timesheet", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated", methods={"GET"})
* @Security("is_granted('view_own_timesheet')")
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
*/
public function indexAction($page, Request $request)
{
@@ -56,6 +56,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());
@@ -65,15 +71,49 @@ class TimesheetController extends AbstractController
return $this->render('timesheet/index.html.twig', [
'entries' => $entries,
'page' => $page,
'page' => $query->getPage(),
'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"})
* @Security("is_granted('export_own_timesheet')")
*
* @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,
]);
}
/**
* @return \Symfony\Component\HttpFoundation\Response
*/
public function activeEntriesAction()
@@ -90,8 +130,7 @@ class TimesheetController extends AbstractController
/**
* The route to stop a running entry.
*
* @Route("/{id}/stop", name="timesheet_stop")
* @Method({"GET"})
* @Route(path="/{id}/stop", name="timesheet_stop", methods={"GET"})
* @Security("is_granted('stop', entry)")
*
* @param Timesheet $entry
@@ -103,21 +142,36 @@ class TimesheetController extends AbstractController
}
/**
* The route to stop a running entry.
*
* @Route("/start/{id}", name="timesheet_start", requirements={"id" = "\d+"})
* @Method({"GET", "POST"})
* @Security("is_granted('start', activity)")
* @Route(path="/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}, methods={"GET", "POST"})
* @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);
$this->stopActiveEntries($user);
$entityManager->flush();
$this->flashSuccess('timesheet.start.success');
}
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}
@@ -126,10 +180,7 @@ class TimesheetController extends AbstractController
}
/**
* The route to edit an existing entry.
*
* @Route("/{id}/edit", name="timesheet_edit")
* @Method({"GET", "POST"})
* @Route(path="/{id}/edit", name="timesheet_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', entry)")
*
* @param Timesheet $entry
@@ -138,28 +189,36 @@ class TimesheetController extends AbstractController
*/
public function editAction(Timesheet $entry, Request $request)
{
return $this->edit($entry, $request, 'timesheet_paginated', 'timesheet/edit.html.twig');
$route = 'timesheet';
if (null !== $request->get('page')) {
$route = 'timesheet_paginated';
} elseif ('calendar' === $request->get('origin')) {
$route = 'calendar';
}
return $this->edit($entry, $request, $route, 'timesheet/edit.html.twig');
}
/**
* The route to create a new entry by form.
*
* @Route("/create", name="timesheet_create")
* @Method({"GET", "POST"})
* @Route(path="/create", name="timesheet_create", methods={"GET", "POST"})
* @Security("is_granted('create_own_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function createAction(Request $request)
{
return $this->create($request, 'timesheet', 'timesheet/edit.html.twig');
$route = 'timesheet';
if ('calendar' === $request->get('origin')) {
$route = 'calendar';
}
return $this->create($request, $route, 'timesheet/edit.html.twig');
}
/**
* The route to delete an existing entry.
*
* @Route("/{id}/delete", name="timesheet_delete")
* @Method({"GET", "POST"})
* @Route(path="/{id}/delete", defaults={"page": 1}, name="timesheet_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', entry)")
*
* @param Timesheet $entry
@@ -173,9 +232,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')]);
@@ -183,31 +242,32 @@ class TimesheetController extends AbstractController
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getCreateForm(Timesheet $entry)
protected function getCreateForm(Timesheet $entry, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_create'),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'action' => $this->generateUrl('timesheet_create', ['origin' => $redirectRoute]),
'include_rate' => $this->isGranted('edit_rate', $entry),
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
protected function getEditForm(Timesheet $entry, $page)
protected function getEditForm(Timesheet $entry, $page, string $redirectRoute)
{
return $this->createForm(TimesheetEditForm::class, $entry, [
'action' => $this->generateUrl('timesheet_edit', [
'id' => $entry->getId(),
'page' => $page
'page' => $page,
'origin' => $redirectRoute,
]),
'method' => 'POST',
'duration_only' => $this->isDurationOnlyMode(),
'include_rate' => $this->isGranted('edit_rate', $entry),
]);
}

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\TimesheetRepository;
use Doctrine\Common\Persistence\ManagerRegistry;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -22,24 +23,24 @@ use Symfony\Component\HttpFoundation\Response;
trait TimesheetControllerTrait
{
/**
* @var bool
* @var int
*/
private $durationOnly = false;
private $hardLimit = 1;
/**
* @param bool $durationOnly
* @param int $hardLimit
*/
protected function setDurationMode(bool $durationOnly)
protected function setHardLimit(int $hardLimit)
{
$this->durationOnly = $durationOnly;
$this->hardLimit = $hardLimit;
}
/**
* @return bool
* @return int
*/
protected function isDurationOnlyMode()
protected function getHardLimit()
{
return $this->durationOnly;
return $this->hardLimit;
}
/**
@@ -76,7 +77,7 @@ trait TimesheetControllerTrait
*/
protected function edit(Timesheet $entry, Request $request, $redirectRoute, $renderTemplate)
{
$editForm = $this->getEditForm($entry, $request->get('page'));
$editForm = $this->getEditForm($entry, $request->get('page'), $request->get('origin', 'timesheet'));
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
@@ -92,13 +93,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')]);
}
@@ -121,7 +120,41 @@ trait TimesheetControllerTrait
$entry->setUser($this->getUser());
$entry->setBegin(new \DateTime());
$createForm = $this->getCreateForm($entry);
$start = $request->get('begin');
if ($start !== null) {
$start = \DateTime::createFromFormat('Y-m-d', $start);
if ($start !== false) {
$start->setTime(10, 0, 0); // TODO make me configurable
$entry->setBegin($start);
}
}
$end = $request->get('end');
if ($end !== null) {
$end = \DateTime::createFromFormat('Y-m-d', $end);
if ($end !== false) {
$end->setTime(18, 0, 0); // TODO make me configurable
$entry->setEnd($end);
}
}
$from = $request->get('from');
if ($from !== null) {
$from = new \DateTime($from);
if ($from !== false) {
$entry->setBegin($from);
}
}
$to = $request->get('to');
if ($to !== null) {
$to = new \DateTime($to);
if ($to !== false) {
$entry->setEnd($to);
}
}
$createForm = $this->getCreateForm($entry, $redirectRoute);
$createForm->handleRequest($request);
if ($createForm->isSubmitted() && $createForm->isValid()) {
@@ -136,14 +169,17 @@ trait TimesheetControllerTrait
}
}
// TODO validate that end is not before begin
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($entry);
$entityManager->flush();
try {
$this->stopActiveEntries($entry->getUser());
$entityManager->persist($entry);
$entityManager->flush();
$this->flashSuccess('action.updated_successfully');
$this->flashSuccess('action.update.success');
} catch (\Exception $ex) {
$this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute($redirectRoute);
}
@@ -154,18 +190,31 @@ trait TimesheetControllerTrait
]);
}
/**
* @param User $user
* @throws \App\Repository\RepositoryException
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
protected function stopActiveEntries(User $user)
{
$this->getRepository()->stopActiveEntries($user, $this->getHardLimit());
}
/**
* @param Timesheet $entry
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
abstract protected function getCreateForm(Timesheet $entry);
abstract protected function getCreateForm(Timesheet $entry, string $redirectRoute);
/**
* @param Timesheet $entry
* @param int $page
* @param string $redirectRoute
* @return \Symfony\Component\Form\FormInterface
*/
abstract protected function getEditForm(Timesheet $entry, $page);
abstract protected function getEditForm(Timesheet $entry, $page, string $redirectRoute);
/**
* Adds a "successful" flash message to the stack.

View File

@@ -0,0 +1,144 @@
<?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\DataFixtures;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
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
* functional tests or while development.
*
* Execute this command to load the data:
* bin/console doctrine:fixtures:load
*/
class CustomerFixtures extends Fixture
{
public const MIN_CUSTOMERS = 5;
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;
public const MAX_ACTIVITIES_PER_PROJECT = 15;
/**
* @param ObjectManager $manager
*/
public function load(ObjectManager $manager)
{
$faker = Factory::create();
$amountCustomers = rand(self::MIN_CUSTOMERS, self::MAX_CUSTOMERS);
for ($c = 1; $c <= $amountCustomers; $c++) {
$visibleCustomer = 0 != $c % 3;
$customer = $this->createCustomer($faker, $visibleCustomer);
$projectForCustomer = rand(self::MIN_PROJECTS_PER_CUSTOMER, self::MAX_PROJECTS_PER_CUSTOMER);
for ($p = 1; $p <= $projectForCustomer; $p++) {
$visibleProject = 0 != $p % 3;
$project = $this->createProject($faker, $customer, $visibleProject);
$manager->persist($project);
$activityForProject = rand(self::MIN_ACTIVITIES_PER_PROJECT, self::MAX_ACTIVITIES_PER_PROJECT);
for ($a = 1; $a <= $activityForProject; $a++) {
$visibleActivity = 0 != $a % 3;
$activity = $this->createActivity($faker, $project, $visibleActivity);
$manager->persist($activity);
}
}
$manager->persist($customer);
$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();
}
/**
* @param Generator $faker
* @param bool $visible
* @return Customer
*/
private function createCustomer(Generator $faker, $visible)
{
$entry = new Customer();
$entry
->setCurrency($faker->currencyCode)
->setName($faker->company . ($visible ? '' : ' (x)'))
->setAddress($faker->address)
->setComment($faker->text)
->setNumber('C-' . $faker->ean8)
->setCountry($faker->countryCode)
->setTimezone($faker->timezone)
->setVisible($visible)
;
return $entry;
}
/**
* @param Generator $faker
* @param Customer $customer
* @param bool $visible
* @return Project
*/
private function createProject(Generator $faker, Customer $customer, $visible)
{
$entry = new Project();
$entry
->setName($faker->catchPhrase . ($visible ? '' : ' (x)'))
->setBudget(rand(self::MIN_BUDGET, self::MAX_BUDGET))
->setComment($faker->text)
->setCustomer($customer)
->setVisible($visible)
;
return $entry;
}
/**
* @param Generator $faker
* @param Project|null $project
* @param bool $visible
* @return Activity
*/
private function createActivity(Generator $faker, ?Project $project, $visible)
{
$entry = new Activity();
$entry
->setName($faker->bs . ($visible ? '' : ' (x)'))
->setProject($project)
->setComment($faker->text)
->setVisible($visible)
;
return $entry;
}
}

View File

@@ -1,50 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DataFixtures;
/**
* Defines reusable sample data.
*/
trait FixturesTrait
{
/**
* @return string[]
*/
protected function getPhrases()
{
return [
'Lorem ipsum dolor sit amet consectetur adipiscing elit',
'Pellentesque vitae velit ex',
'Mauris dapibus risus quis suscipit vulputate',
'Eros diam egestas libero eu vulputate risus',
'In hac habitasse platea dictumst',
'Morbi tempus commodo mattis',
'Ut suscipit posuere justo at vulputate',
'Ut eleifend mauris et risus ultrices egestas',
'Aliquam sodales odio id eleifend tristique',
'Urna nisl sollicitudin id varius orci quam id turpis',
'Nulla porta lobortis ligula vel egestas',
'Curabitur aliquam euismod dolor non ornare',
'Sed varius a risus eget aliquam',
'Nunc viverra elit ac laoreet suscipit',
'Pellentesque et sapien pulvinar consectetur',
];
}
/**
* @return string
*/
protected function getRandomPhrase()
{
$phrases = $this->getPhrases();
return $phrases[array_rand($phrases)];
}
}

View File

@@ -12,6 +12,8 @@ namespace App\DataFixtures;
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
@@ -27,36 +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()
{
$template = new InvoiceTemplate();
$template
->setName('Invoice')
->setTitle('Your company name')
->setCompany('Kimai, Inc.')
->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(
'795 Folsom Ave, Suite 600' . PHP_EOL .
'San Francisco, CA 94107' . PHP_EOL .
'Phone: (804) 123-456789' . PHP_EOL .
'Email: email@example.com'
)
$faker = Factory::create();
$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,37 +10,108 @@
namespace App\DataFixtures;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests or while development.
*
* Execute this command to load the data:
* $ php bin/console doctrine:fixtures:load
* bin/console doctrine:fixtures:load
*/
class TimesheetFixtures extends Fixture
class TimesheetFixtures extends Fixture implements DependentFixtureInterface
{
use FixturesTrait;
public const MIN_TIMESHEETS_PER_USER = 50;
public const MAX_TIMESHEETS_PER_USER = 500;
public const MAX_TIMESHEETS_TOTAL = 5000;
public const MIN_RUNNING_TIMESHEETS_PER_USER = 0;
public const MAX_RUNNING_TIMESHEETS_PER_USER = 3;
public const TIMERANGE_DAYS = 1095; // 3 years
public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
public const AMOUNT_TIMESHEET = 5000; // timesheet entries total
public const RATE_MIN = 10; // minimum rate for one hour
public const RATE_MAX = 80; // maximum rate for one hour
public const BATCH_SIZE = 100;
/**
* @return array
*/
public function getDependencies()
{
return [
UserFixtures::class,
CustomerFixtures::class,
];
}
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
$this->loadCustomers($manager);
$this->loadProjects($manager);
$this->loadActivities($manager);
$this->loadTimesheet($manager);
$allUser = $this->getAllUsers($manager);
$activities = $this->getAllActivities($manager);
$projects = $this->getAllProjects($manager);
$faker = Factory::create();
// by using array_pop we make sure that at least one activity has NO entry!
array_pop($activities);
foreach ($allUser as $user) {
// random amount of timesheet entries for every user
$timesheetForUser = rand(self::MIN_TIMESHEETS_PER_USER, self::MAX_TIMESHEETS_PER_USER);
for ($i = 1; $i <= $timesheetForUser; $i++) {
if ($i > self::MAX_TIMESHEETS_TOTAL) {
break;
}
$description = null;
if ($i % 3 == 0) {
$description = $faker->text;
} elseif ($i % 7 == 0) {
$description = '';
}
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
$description
);
$manager->persist($entry);
if ($i % self::BATCH_SIZE == 0) {
//echo '['.$i.'] Timesheets for User ' . $user->getId() . PHP_EOL;
$manager->flush();
$manager->clear(Timesheet::class);
}
}
// create active recordings for test user
$activeEntries = rand(self::MIN_RUNNING_TIMESHEETS_PER_USER, self::MAX_RUNNING_TIMESHEETS_PER_USER);
for ($i = 0; $i < $activeEntries; $i++) {
$entry = $this->createTimesheetEntry(
$user,
$activities[array_rand($activities)],
$projects[array_rand($projects)],
null,
false
);
$manager->persist($entry);
}
$manager->flush();
$manager->clear(Timesheet::class);
}
$manager->flush();
}
/**
@@ -59,22 +130,6 @@ class TimesheetFixtures extends Fixture
return $all;
}
/**
* @param ObjectManager $manager
* @return Customer[]
*/
protected function getAllCustomers(ObjectManager $manager)
{
$all = [];
/* @var Customer[] $entries */
$entries = $manager->getRepository(Customer::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
@@ -107,338 +162,33 @@ class TimesheetFixtures extends Fixture
return $all;
}
private function loadTimesheet(ObjectManager $manager)
{
$allUser = $this->getAllUsers($manager);
$amountUser = count($allUser);
$allActivity = $this->getAllActivities($manager);
// by using array_pop we make sure that at least one activity has NO entry!
array_pop($allActivity);
for ($i = 0; $i <= self::AMOUNT_TIMESHEET; $i++) {
$entry = $this->createTimesheetEntry(
$allUser[rand(1, $amountUser)],
$allActivity[array_rand($allActivity)],
round($i / 2),
true
);
$manager->persist($entry);
}
// by using array_pop we make sure that at least one user has NO running entry!
array_pop($allUser);
// create active recodinge for test user
foreach ($allUser as $id => $user) {
for ($i = 0; $i < rand(1, 4); $i++) {
$entry = $this->createTimesheetEntry(
$user,
$allActivity[array_rand($allActivity)]
);
$manager->persist($entry);
}
}
$manager->flush();
}
private function createTimesheetEntry(User $user, Activity $activity, $startDay = 0, $setEndDate = false)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, $setEndDate = true)
{
$start = new \DateTime();
if ($startDay > 0) {
$start = $start->modify('- ' . (rand(1, $startDay)) . ' days');
}
$start = $start->modify('- ' . (rand(1, self::TIMERANGE_DAYS)) . ' days');
$start = $start->modify('- ' . (rand(1, 86400)) . ' seconds');
$end = clone $start;
$end = $end->modify('+ ' . (rand(1, 43200)) . ' seconds');
//$duration = $end->modify('- ' . $start->getTimestamp() . ' seconds')->getTimestamp();
$duration = $end->getTimestamp() - $start->getTimestamp();
$rate = rand(self::RATE_MIN, self::RATE_MAX);
$entry = new Timesheet();
$entry
->setActivity($activity)
->setDescription($this->getRandomPhrase())
->setProject($activity->getProject() ?? $project)
->setDescription($description)
->setUser($user)
->setRate(round(($duration / 3600) * $rate))
->setBegin($start);
if ($setEndDate) {
$end = clone $start;
$end = $end->modify('+ ' . (rand(self::MIN_MINUTES_PER_ENTRY, self::MAX_MINUTES_PER_ENTRY)) . ' minutes');
$duration = $end->getTimestamp() - $start->getTimestamp();
$rate = $user->getPreferenceValue(UserPreference::HOURLY_RATE);
$entry
->setEnd($end)
->setRate(round(($duration / 3600) * $rate))
->setDuration($duration);
}
return $entry;
}
private function loadCustomers(ObjectManager $manager)
{
$allTimezones = \DateTimeZone::listIdentifiers();
$amountTimezone = count($allTimezones);
$allCustomer = $this->getCustomers();
shuffle($allCustomer);
$i = 1;
foreach ($allCustomer as $customerName) {
$visible = 0 != $i++ % 6;
$entry = new Customer();
$entry
->setCurrency($this->getRandomCurrency())
->setName($customerName . ($visible ? '' : '.'))
->setAddress($this->getRandomLocation())
->setComment($this->getRandomPhrase())
->setVisible($visible)
->setNumber('C0815-42-' . $i)
->setCountry('DE') // TODO randomize country ?
->setTimezone($allTimezones[rand(1, $amountTimezone)]);
$manager->persist($entry);
}
$manager->flush();
}
private function loadProjects(ObjectManager $manager)
{
$allCustomer = $this->getAllCustomers($manager);
foreach ($allCustomer as $id => $customer) {
$projectForCustomer = rand(0, 7);
for ($i = 1; $i <= $projectForCustomer; $i++) {
$visible = 0 != $i % 5;
$entry = new Project();
$entry
->setName($this->getRandomProject() . ($visible ? '' : '.'))
->setBudget(rand(500, 100000))
->setComment($this->getRandomPhrase())
->setCustomer($customer)
->setVisible($visible);
$manager->persist($entry);
}
}
$manager->flush();
}
private function loadActivities(ObjectManager $manager)
{
$allProject = $this->getAllProjects($manager);
foreach ($allProject as $projectId => $project) {
$activityCount = rand(0, 10);
for ($i = 1; $i <= $activityCount; $i++) {
$visible = 0 != $i % 4;
$entry = new Activity();
$entry
->setName($this->getRandomActivity() . ($visible ? '' : '.'))
->setProject($project)
->setComment($this->getRandomPhrase())
->setVisible($visible);
$manager->persist($entry);
}
}
$manager->flush();
}
/**
* @return string[]
*/
private function getActivities()
{
return [
'Designing',
'Programming',
'Testing',
'Documentation',
'Pause',
'Research',
'Meeting',
'Hosting',
'Relaunch',
'Support',
'Refactoring',
'Interview',
'Administration',
'DevOps',
'Management',
'Setup',
'Planning',
'Skiing',
'Eating',
'Watching TV',
'Talking',
'Cooking',
'Writing',
'Reading',
'Brainstroming',
'Post Processing',
];
}
/**
* @return string
*/
private function getRandomActivity()
{
$all = $this->getActivities();
return $all[array_rand($all)];
}
/**
* @return string[]
*/
private function getProjects()
{
return [
'User Experience',
'Database Migration',
'Test Automatisation',
'Website Redesign',
'API Development',
'Hosting & Server',
'Customer Relations',
'Infrastructure',
'Princess Cat',
'Software Upgrade',
'Office Management',
'Project X',
'Customer Excellence',
'Crazy Monkey',
'Interface Design',
'Human Ressources',
'Book Release',
'Studio Photography',
'Professional Art',
'Video Production',
];
}
/**
* @return string
*/
private function getRandomProject()
{
$all = $this->getProjects();
return $all[array_rand($all)];
}
/**
* @return string[]
*/
private function getLocations()
{
return [
'Köln',
'München',
'New York',
'Buenos Aires',
'Hawai',
'Amsterdam',
'London',
'San Francisco',
'Tokyo',
'Berlin',
'Sao Paulo',
'Mexico City',
'Moscow',
'Sankt Petersburg',
'Taiwan',
'Perth',
'Sydney',
'Mumbai',
'Lagos',
'Karachi',
'Shanghai',
'Delhi',
];
}
/**
* @return string
*/
private function getRandomLocation()
{
$all = $this->getLocations();
return $all[array_rand($all)];
}
/**
* @return string[]
*/
private function getCustomers()
{
return [
'Acme University',
'Snake Oil',
'Apple',
'Microsoft',
'Google',
'Oracle',
'Yahoo',
'Twitter',
'Zend',
'SensioLabs',
'Samsung',
'Huawai',
'Yandex',
'Baidu',
'Alphabet',
'Amazon.com',
'Berkshire Hathaway',
'Facebook',
'ExxonMobil',
'Nestle',
'Johnson & Johnson',
'Alibaba',
'General Electric',
'Procter & Gamble',
'Wal-Mart Stores',
'Novartis',
'Coca-Cola',
'Wikipedia',
'Walt Disney',
'Merck',
'Pfizer',
"L'Oréal Group",
"McDonald's",
'China Petroleum & Chemical',
'GlaxoSmithKline'
];
}
/**
* @return string[]
*/
private function getCurrencies()
{
return [
'EUR',
'GBP',
'USD',
'RUB',
'JPY',
'CNY',
'INR'
];
}
/**
* @return string
*/
private function getRandomCurrency()
{
$all = $this->getCurrencies();
return $all[array_rand($all)];
}
}

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Entity\UserPreference;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
/**
@@ -22,23 +23,30 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
* Execute this command to load the data:
* $ php bin/console doctrine:fixtures:load
*/
class AppFixtures extends Fixture
class UserFixtures extends Fixture
{
use FixturesTrait;
public const DEFAULT_PASSWORD = 'kitten';
public const DEFAULT_API_TOKEN = 'api_kitten';
public const DEFAULT_AVATAR = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y';
public const USERNAME_USER = 'john_user';
public const USERNAME_TEAMLEAD = 'tony_teamlead';
public const USERNAME_ADMIN = 'anna_admin';
public const USERNAME_SUPER_ADMIN = 'susan_super';
public const AMOUNT_EXTRA_USER = 2;
public const MIN_RATE = 30;
public const MAX_RATE = 120;
public const BATCH_SIZE = 50;
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
/**
* AppFixtures constructor.
* @param UserPasswordEncoderInterface $encoder
*/
public function __construct(UserPasswordEncoderInterface $encoder)
@@ -51,17 +59,21 @@ class AppFixtures extends Fixture
*/
public function load(ObjectManager $manager)
{
$this->loadUsers($manager);
$this->loadDefaultAccounts($manager);
$this->loadTestUsers($manager);
}
/**
* Default users for all test cases
*
* @param ObjectManager $manager
*/
private function loadUsers(ObjectManager $manager)
private function loadDefaultAccounts(ObjectManager $manager)
{
$passwordEncoder = $this->encoder;
foreach ($this->getUserDefinition() as $userData) {
$allUsers = $this->getUserDefinition();
foreach ($allUsers as $userData) {
$user = new User();
$user
->setAlias($userData[0])
@@ -72,18 +84,65 @@ class AppFixtures extends Fixture
->setAvatar($userData[5])
->setEnabled($userData[6])
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN))
->setPreferences([$this->getUserPreference($user)])
;
$preference = new UserPreference();
$preference->setName(UserPreference::HOURLY_RATE);
$preference->setValue(rand(0, 100));
$preference->setUser($user);
$user->setPreferences([$preference]);
$manager->persist($user);
}
$manager->flush();
$manager->clear();
}
/**
* @param User $user
* @return UserPreference
*/
private function getUserPreference(user $user)
{
$preference = new UserPreference();
$preference->setName(UserPreference::HOURLY_RATE);
$preference->setValue(rand(self::MIN_RATE, self::MAX_RATE));
$preference->setUser($user);
return $preference;
}
/**
* Generate randomized test users, which don't have API access.
*
* @param ObjectManager $manager
*/
private function loadTestUsers(ObjectManager $manager)
{
$passwordEncoder = $this->encoder;
$faker = Factory::create();
for ($i = 1; $i <= self::AMOUNT_EXTRA_USER; $i++) {
$user = new User();
$user
->setAlias($faker->name)
->setTitle(substr($faker->jobTitle, 0, 49))
->setUsername($faker->userName)
->setEmail($faker->email)
->setRoles([User::ROLE_USER])
->setAvatar(self::DEFAULT_AVATAR)
->setEnabled(true)
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setPreferences([$this->getUserPreference($user)])
;
if ($i % self::BATCH_SIZE == 0) {
$manager->flush();
$manager->clear();
}
$manager->persist($user);
}
$manager->flush();
$manager->clear();
}
/**
@@ -93,29 +152,29 @@ class AppFixtures extends Fixture
{
return [
[
'Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', 'ROLE_CUSTOMER',
'Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', User::ROLE_CUSTOMER,
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', true
],
[
'John Doe', 'Developer', self::USERNAME_USER, 'john_user@example.com', 'ROLE_USER',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', true
'John Doe', 'Developer', self::USERNAME_USER, 'john_user@example.com', User::ROLE_USER,
self::DEFAULT_AVATAR, true
],
// inactive user to test login
[
'Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', 'ROLE_USER',
'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false
'Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', User::ROLE_USER,
self::DEFAULT_AVATAR, false
],
[
'Tony Maier', 'Head of Sales', self::USERNAME_TEAMLEAD, 'tony_teamlead@example.com', 'ROLE_TEAMLEAD',
'Tony Maier', 'Head of Sales', self::USERNAME_TEAMLEAD, 'tony_teamlead@example.com', User::ROLE_TEAMLEAD,
'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true
],
// no avatar to test default image macro
[
'Anna Smith', 'Administrator', self::USERNAME_ADMIN, 'anna_admin@example.com', 'ROLE_ADMIN', null, true
'Anna Smith', 'Administrator', self::USERNAME_ADMIN, 'anna_admin@example.com', User::ROLE_ADMIN, null, true
],
// no alias to test twig username macro
[
null, 'Super Administrator', self::USERNAME_SUPER_ADMIN, 'susan_super@example.com', 'ROLE_SUPER_ADMIN',
null, 'Super Administrator', self::USERNAME_SUPER_ADMIN, 'susan_super@example.com', User::ROLE_SUPER_ADMIN,
'/build/images/default_avatar.png', true
]
];

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