added avatars, show user teams in list, new team dashboard widgets (#1150)

This commit is contained in:
Kevin Papst
2019-10-03 13:44:16 +02:00
committed by GitHub
parent 718ad4b398
commit e8c25d8ac5
120 changed files with 2585 additions and 642 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@
/config/packages/local.yaml /config/packages/local.yaml
/config/packages/*/local.yaml /config/packages/*/local.yaml
public/avatars/*.png
templates/invoice/renderer/.~lock* templates/invoice/renderer/.~lock*
/var/data/* /var/data/*

View File

@@ -10,9 +10,18 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.4](https://github.com/kevinpapst/kimai2/releases/tag/1.4) ## [1.4](https://github.com/kevinpapst/kimai2/releases/tag/1.4)
**Attention**: There is a new directory, which needs to be writable by the webserver: `public/avatars/`.
New permission (used in new dashboard widget):
- `view_team_member` - view the teamlead and members for the teams of the current user
### Possible BC breaks ### Possible BC breaks
- Activate Javascript select component by default - Activated Javascript select component by default
### BC breaks for devs
- Dashboard widgets and rows need to define their `type` by FQCN
- Switched to Symfony 4.3 event types, this could fail in plugins only if they didn't use the official constants for event names - Switched to Symfony 4.3 event types, this could fail in plugins only if they didn't use the official constants for event names
## [1.3](https://github.com/kevinpapst/kimai2/releases/tag/1.3) ## [1.3](https://github.com/kevinpapst/kimai2/releases/tag/1.3)

View File

@@ -35,3 +35,17 @@ td {
line-height: 35px; line-height: 35px;
margin-right: 15px; margin-right: 15px;
} }
/* Profile */
.box-profile {
img.img-circle {
width: 100px;
}
.profile-username {
margin-top: 15px;
}
}
.label-gray {
background-color: $gray-lte;
}

View File

@@ -50,6 +50,7 @@ table.dataTable {
font-size: 85%; font-size: 85%;
font-weight: 500; font-weight: 500;
padding: .3em .6em .3em; padding: .3em .6em .3em;
line-height: 24px;
} }
span.label-activity, span.label-activity,
span.label-project, span.label-project,

View File

@@ -270,3 +270,14 @@ div.dataTables_scrollHead table.table-bordered {
table.dataTable th.sortable { table.dataTable th.sortable {
padding-left: 22px; padding-left: 22px;
} }
table.dataTable.table > tbody > tr > td {
&.avatars {
line-height: 40px;
}
vertical-align: middle;
.img-circle {
max-width: 35px;
margin-right: 10px;
}
}

View File

@@ -6,6 +6,10 @@
*/ */
.navbar-nav { .navbar-nav {
img.img-circle {
max-height: 26px;
margin-top: -2px;
}
li>a.ddt-large { li>a.ddt-large {
padding: 15px 12px 11px 10px; padding: 15px 12px 11px 10px;
font-size: 10px; font-size: 10px;

View File

@@ -26,6 +26,7 @@
"jms/serializer-bundle": "^3.2", "jms/serializer-bundle": "^3.2",
"kevinpapst/adminlte-bundle": "~3.0", "kevinpapst/adminlte-bundle": "~3.0",
"kimai/kimai2-composer": "^0.1", "kimai/kimai2-composer": "^0.1",
"laravolt/avatar": "^3.0",
"mpdf/mpdf": "^7.1", "mpdf/mpdf": "^7.1",
"nelmio/api-doc-bundle": "^3.2", "nelmio/api-doc-bundle": "^3.2",
"nelmio/cors-bundle": "^1.5", "nelmio/cors-bundle": "^1.5",
@@ -119,7 +120,8 @@
"kimai:code-check": [ "kimai:code-check": [
"@kimai:codestyle", "@kimai:codestyle",
"@kimai:phpstan", "@kimai:phpstan",
"@kimai:tests" "@kimai:tests-unit",
"@kimai:tests-integration"
], ],
"kimai:tests": "vendor/bin/phpunit tests/", "kimai:tests": "vendor/bin/phpunit tests/",
"kimai:tests-unit": "vendor/bin/phpunit --exclude-group integration tests/", "kimai:tests-unit": "vendor/bin/phpunit --exclude-group integration tests/",

533
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "36497a607713888a9a39731a007a9b90", "content-hash": "7aa966dd59dc7f6c13c113695ed0b56b",
"packages": [ "packages": [
{ {
"name": "beberlei/doctrineextensions", "name": "beberlei/doctrineextensions",
@@ -106,16 +106,16 @@
}, },
{ {
"name": "doctrine/annotations", "name": "doctrine/annotations",
"version": "v1.7.0", "version": "v1.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/annotations.git", "url": "https://github.com/doctrine/annotations.git",
"reference": "fa4c4e861e809d6a1103bd620cce63ed91aedfeb" "reference": "904dca4eb10715b92569fbcd79e201d5c349b6bc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/annotations/zipball/fa4c4e861e809d6a1103bd620cce63ed91aedfeb", "url": "https://api.github.com/repos/doctrine/annotations/zipball/904dca4eb10715b92569fbcd79e201d5c349b6bc",
"reference": "fa4c4e861e809d6a1103bd620cce63ed91aedfeb", "reference": "904dca4eb10715b92569fbcd79e201d5c349b6bc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -124,7 +124,7 @@
}, },
"require-dev": { "require-dev": {
"doctrine/cache": "1.*", "doctrine/cache": "1.*",
"phpunit/phpunit": "^7.5@dev" "phpunit/phpunit": "^7.5"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -170,7 +170,7 @@
"docblock", "docblock",
"parser" "parser"
], ],
"time": "2019-08-08T18:11:40+00:00" "time": "2019-10-01T18:55:10+00:00"
}, },
{ {
"name": "doctrine/cache", "name": "doctrine/cache",
@@ -1760,6 +1760,77 @@
], ],
"time": "2019-03-17T18:16:12+00:00" "time": "2019-03-17T18:16:12+00:00"
}, },
{
"name": "guzzlehttp/psr7",
"version": "1.6.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
"reference": "239400de7a173fe9901b9ac7c06497751f00727a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a",
"reference": "239400de7a173fe9901b9ac7c06497751f00727a",
"shasum": ""
},
"require": {
"php": ">=5.4.0",
"psr/http-message": "~1.0",
"ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
},
"provide": {
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"ext-zlib": "*",
"phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8"
},
"suggest": {
"zendframework/zend-httphandlerrunner": "Emit PSR-7 responses"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.6-dev"
}
},
"autoload": {
"psr-4": {
"GuzzleHttp\\Psr7\\": "src/"
},
"files": [
"src/functions_include.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
},
{
"name": "Tobias Schultze",
"homepage": "https://github.com/Tobion"
}
],
"description": "PSR-7 message implementation that also provides common utility methods",
"keywords": [
"http",
"message",
"psr-7",
"request",
"response",
"stream",
"uri",
"url"
],
"time": "2019-07-01T23:21:34+00:00"
},
{ {
"name": "hoa/compiler", "name": "hoa/compiler",
"version": "3.17.08.08", "version": "3.17.08.08",
@@ -2543,6 +2614,232 @@
], ],
"time": "2017-01-10T10:39:54+00:00" "time": "2017-01-10T10:39:54+00:00"
}, },
{
"name": "illuminate/cache",
"version": "v6.1.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/cache.git",
"reference": "ee6306c9af2ab5d7f39707c40419b74de431fbc2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/cache/zipball/ee6306c9af2ab5d7f39707c40419b74de431fbc2",
"reference": "ee6306c9af2ab5d7f39707c40419b74de431fbc2",
"shasum": ""
},
"require": {
"illuminate/contracts": "^6.0",
"illuminate/support": "^6.0",
"php": "^7.2"
},
"suggest": {
"ext-memcached": "Required to use the memcache cache driver.",
"illuminate/database": "Required to use the database cache driver (^6.0).",
"illuminate/filesystem": "Required to use the file cache driver (^6.0).",
"illuminate/redis": "Required to use the redis cache driver (^6.0).",
"symfony/cache": "Required to PSR-6 cache bridge (^4.3.4)."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.0-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Cache\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Cache package.",
"homepage": "https://laravel.com",
"time": "2019-09-11T12:03:36+00:00"
},
{
"name": "illuminate/contracts",
"version": "v6.1.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/contracts.git",
"reference": "403b24e356346c1cd13ad794d87ec7c57a5363bb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/contracts/zipball/403b24e356346c1cd13ad794d87ec7c57a5363bb",
"reference": "403b24e356346c1cd13ad794d87ec7c57a5363bb",
"shasum": ""
},
"require": {
"php": "^7.2",
"psr/container": "^1.0",
"psr/simple-cache": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.0-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Contracts\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Contracts package.",
"homepage": "https://laravel.com",
"time": "2019-09-18T12:32:21+00:00"
},
{
"name": "illuminate/support",
"version": "v6.1.0",
"source": {
"type": "git",
"url": "https://github.com/illuminate/support.git",
"reference": "db160b42dce40ca73f12e8a7ccd227b4e242df10"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/illuminate/support/zipball/db160b42dce40ca73f12e8a7ccd227b4e242df10",
"reference": "db160b42dce40ca73f12e8a7ccd227b4e242df10",
"shasum": ""
},
"require": {
"doctrine/inflector": "^1.1",
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/contracts": "^6.0",
"nesbot/carbon": "^2.0",
"php": "^7.2"
},
"conflict": {
"tightenco/collect": "<5.5.33"
},
"suggest": {
"illuminate/filesystem": "Required to use the composer class (^6.0).",
"moontoast/math": "Required to use ordered UUIDs (^1.1).",
"ramsey/uuid": "Required to use Str::uuid() (^3.7).",
"symfony/process": "Required to use the composer class (^4.3.4).",
"symfony/var-dumper": "Required to use the dd function (^4.3.4).",
"vlucas/phpdotenv": "Required to use the Env class and env helper (^3.3)."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "6.0-dev"
}
},
"autoload": {
"psr-4": {
"Illuminate\\Support\\": ""
},
"files": [
"helpers.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "The Illuminate Support package.",
"homepage": "https://laravel.com",
"time": "2019-09-30T12:55:23+00:00"
},
{
"name": "intervention/image",
"version": "2.5.0",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "39eaef720d082ecc54c64bf54541c55f10db546d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/39eaef720d082ecc54c64bf54541c55f10db546d",
"reference": "39eaef720d082ecc54c64bf54541c55f10db546d",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"guzzlehttp/psr7": "~1.1",
"php": ">=5.4.0"
},
"require-dev": {
"mockery/mockery": "~0.9.2",
"phpunit/phpunit": "^4.8 || ^5.7"
},
"suggest": {
"ext-gd": "to use GD library based image processing.",
"ext-imagick": "to use Imagick based image processing.",
"intervention/imagecache": "Caching extension for the Intervention Image library"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.4-dev"
},
"laravel": {
"providers": [
"Intervention\\Image\\ImageServiceProvider"
],
"aliases": {
"Image": "Intervention\\Image\\Facades\\Image"
}
}
},
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src/Intervention/Image"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@olivervogel.com",
"homepage": "http://olivervogel.com/"
}
],
"description": "Image handling and manipulation library with support for Laravel integration",
"homepage": "http://image.intervention.io/",
"keywords": [
"gd",
"image",
"imagick",
"laravel",
"thumbnail",
"watermark"
],
"time": "2019-06-24T14:06:31+00:00"
},
{ {
"name": "jdorn/sql-formatter", "name": "jdorn/sql-formatter",
"version": "v1.2.17", "version": "v1.2.17",
@@ -2924,6 +3221,71 @@
], ],
"time": "2019-03-10T19:34:31+00:00" "time": "2019-03-10T19:34:31+00:00"
}, },
{
"name": "laravolt/avatar",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravolt/avatar.git",
"reference": "d95652df4499790e25d66a4fbfc34dfb93e7314d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravolt/avatar/zipball/d95652df4499790e25d66a4fbfc34dfb93e7314d",
"reference": "d95652df4499790e25d66a4fbfc34dfb93e7314d",
"shasum": ""
},
"require": {
"illuminate/cache": "~5.6 | 6.*",
"illuminate/support": "~5.6 | 6.*",
"intervention/image": "^2.5",
"php": ">=7.1.3"
},
"require-dev": {
"mockery/mockery": "^0.9.1",
"phpunit/phpunit": "~7.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
},
"laravel": {
"providers": [
"Laravolt\\Avatar\\ServiceProvider"
],
"aliases": {
"Avatar": "Laravolt\\Avatar\\Facade"
}
}
},
"autoload": {
"psr-4": {
"Laravolt\\Avatar\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Bayu Hendra Winata",
"email": "uyab.exe@gmail.com",
"homepage": "http://id-laravel.com",
"role": "Developer"
}
],
"description": "Turn name, email, and any other string into initial-based avatar or gravatar.",
"homepage": "https://github.com/laravolt/avatar",
"keywords": [
"avatar",
"gravatar",
"laravel",
"laravolt"
],
"time": "2019-09-04T14:39:26+00:00"
},
{ {
"name": "markbaker/complex", "name": "markbaker/complex",
"version": "1.4.7", "version": "1.4.7",
@@ -3424,6 +3786,73 @@
], ],
"time": "2019-06-17T08:53:14+00:00" "time": "2019-06-17T08:53:14+00:00"
}, },
{
"name": "nesbot/carbon",
"version": "2.24.0",
"source": {
"type": "git",
"url": "https://github.com/briannesbitt/Carbon.git",
"reference": "934459c5ac0658bc765ad1e53512c7c77adcac29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/934459c5ac0658bc765ad1e53512c7c77adcac29",
"reference": "934459c5ac0658bc765ad1e53512c7c77adcac29",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.1.8 || ^8.0",
"symfony/translation": "^3.4 || ^4.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.14 || ^3.0",
"kylekatarnls/multi-tester": "^1.1",
"phpmd/phpmd": "dev-php-7.1-compatibility",
"phpstan/phpstan": "^0.11",
"phpunit/phpunit": "^7.5 || ^8.0",
"squizlabs/php_codesniffer": "^3.4"
},
"bin": [
"bin/carbon"
],
"type": "library",
"extra": {
"laravel": {
"providers": [
"Carbon\\Laravel\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Carbon\\": "src/Carbon/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Brian Nesbitt",
"email": "brian@nesbot.com",
"homepage": "http://nesbot.com"
},
{
"name": "kylekatarnls",
"homepage": "http://github.com/kylekatarnls"
}
],
"description": "A API extension for DateTime that supports 281 different languages.",
"homepage": "http://carbon.nesbot.com",
"keywords": [
"date",
"datetime",
"time"
],
"time": "2019-08-31T16:37:55+00:00"
},
{ {
"name": "ocramius/package-versions", "name": "ocramius/package-versions",
"version": "1.4.0", "version": "1.4.0",
@@ -4196,6 +4625,56 @@
], ],
"time": "2017-02-14T16:28:37+00:00" "time": "2017-02-14T16:28:37+00:00"
}, },
{
"name": "psr/http-message",
"version": "1.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-message.git",
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
"reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
"shasum": ""
},
"require": {
"php": ">=5.3.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Message\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "http://www.php-fig.org/"
}
],
"description": "Common interface for HTTP messages",
"homepage": "https://github.com/php-fig/http-message",
"keywords": [
"http",
"http-message",
"psr",
"psr-7",
"request",
"response"
],
"time": "2016-08-06T14:39:51+00:00"
},
{ {
"name": "psr/log", "name": "psr/log",
"version": "1.1.0", "version": "1.1.0",
@@ -4291,6 +4770,46 @@
], ],
"time": "2017-10-23T01:57:42+00:00" "time": "2017-10-23T01:57:42+00:00"
}, },
{
"name": "ralouphie/getallheaders",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/ralouphie/getallheaders.git",
"reference": "120b605dfeb996808c31b6477290a714d356e822"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
"reference": "120b605dfeb996808c31b6477290a714d356e822",
"shasum": ""
},
"require": {
"php": ">=5.6"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.1",
"phpunit/phpunit": "^5 || ^6.5"
},
"type": "library",
"autoload": {
"files": [
"src/getallheaders.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ralph Khattar",
"email": "ralph.khattar@gmail.com"
}
],
"description": "A polyfill for getallheaders.",
"time": "2019-03-08T08:55:37+00:00"
},
{ {
"name": "sensio/framework-extra-bundle", "name": "sensio/framework-extra-bundle",
"version": "v5.4.1", "version": "v5.4.1",

View File

@@ -102,12 +102,12 @@ kimai:
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet'] EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team'] TEAMS: ['view_team','create_team','edit_team','delete_team']
# some single default definitions for roles # some single default definitions for roles
SINGLE_USER: [] SINGLE_USER: ['view_team_member','budget_team_project']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile'] SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile'] SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile'] SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member']
# link above sets to one set for each user role # link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE'] ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD'] ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN'] ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN'] ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
@@ -171,16 +171,21 @@ kimai:
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
dashboard: dashboard:
user_duration: user_duration:
title: dashboard.you title: stats.yourWorkingHours
order: 10 order: 10
permission: ROLE_USER permission: ROLE_USER
type: 'compoundChart' type: '\App\Widget\Type\CompoundChart'
widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear] widgets: [DailyWorkingTimeChart, userDurationToday, userDurationWeek, userDurationMonth, userDurationYear]
user_rates: user_rates:
title: ~ title: ~
order: 20 order: 20
permission: view_rate_own_timesheet permission: view_rate_own_timesheet
widgets: [userAmountToday, userAmountWeek, userAmountMonth, userAmountYear] widgets: [userAmountToday, userAmountWeek, userAmountMonth, userAmountYear]
user_teams:
title: ~
order: 15
permission: ROLE_USER
widgets: [UserTeams, UserTeamProjects]
duration: duration:
title: dashboard.all title: dashboard.all
order: 30 order: 30

0
public/avatars/.gitignore vendored Normal file
View File

File diff suppressed because one or more lines are too long

View File

@@ -8,7 +8,7 @@
"build/app.a361daf0.js" "build/app.a361daf0.js"
], ],
"css": [ "css": [
"build/app.67d3e582.css" "build/app.4bdd4f2d.css"
] ]
}, },
"chart": { "chart": {
@@ -36,7 +36,7 @@
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok", "build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI", "build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.a361daf0.js": "sha384-gIDdRGs/guN+3m3fK/QhrAX8s3aSKfwwwE2VQBKDP4RtEYK3Uf/7WqEfqwrsT7HZ", "build/app.a361daf0.js": "sha384-gIDdRGs/guN+3m3fK/QhrAX8s3aSKfwwwE2VQBKDP4RtEYK3Uf/7WqEfqwrsT7HZ",
"build/app.67d3e582.css": "sha384-z31E1nIpFQPyWAINtMd/rSW1pCQ4lHj3fT4BQlQHRoL0NxLFHqeRsvXuzkJiewd5", "build/app.4bdd4f2d.css": "sha384-XpqGJV9ny/Fk0uJHisAZrAZfWfkzDvm5RJQOaa9ozf6nJstSXyEHziVAza9fIifm",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q", "build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.c55aa90f.js": "sha384-72C6gaIwYSeErJb6b/UbbzgnNHAlqQW/4/J0tXisV6Qdd78PeF7VT3xpfb1r1YOW", "build/calendar.c55aa90f.js": "sha384-72C6gaIwYSeErJb6b/UbbzgnNHAlqQW/4/J0tXisV6Qdd78PeF7VT3xpfb1r1YOW",

View File

@@ -2,7 +2,7 @@
"build/0.a87622f3.js": "build/0.a87622f3.js", "build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js", "build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js", "build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.67d3e582.css", "build/app.css": "build/app.4bdd4f2d.css",
"build/app.js": "build/app.a361daf0.js", "build/app.js": "build/app.a361daf0.js",
"build/calendar.css": "build/calendar.36ef2a02.css", "build/calendar.css": "build/calendar.36ef2a02.css",
"build/calendar.js": "build/calendar.c55aa90f.js", "build/calendar.js": "build/calendar.c55aa90f.js",

View File

@@ -73,10 +73,6 @@ class UserController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher) public function cgetAction(ParamFetcherInterface $paramFetcher)
{ {
$query = new UserQuery(); $query = new UserQuery();
$query
->setResultType(UserQuery::RESULT_TYPE_OBJECTS)
->setOrderBy('username')
;
if (null !== ($visible = $paramFetcher->get('visible'))) { if (null !== ($visible = $paramFetcher->get('visible'))) {
$query->setVisibility($visible); $query->setVisibility($visible);
@@ -90,7 +86,7 @@ class UserController extends BaseApiController
$query->setOrderBy($orderBy); $query->setOrderBy($orderBy);
} }
$data = $this->repository->findByQuery($query); $data = $this->repository->getUsersForQuery($query);
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Collection', 'User']); $view->getContext()->setGroups(['Default', 'Collection', 'User']);

View File

@@ -10,7 +10,7 @@
namespace App\Controller; namespace App\Controller;
use App\Constants; use App\Constants;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
@@ -31,65 +31,10 @@ class AboutController extends AbstractController
$this->projectDirectory = $projectDirectory; $this->projectDirectory = $projectDirectory;
} }
/**
* @Route(path="/debug", name="about_debug", methods={"GET"})
*
* @Security("is_granted('system_information')")
*
* @return \Symfony\Component\HttpFoundation\Response
*/
public function debugAction()
{
$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('about/system.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'dotenv' => [
'APP_ENV' => getenv('APP_ENV'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
],
'info' => $phpInfo,
'settings' => $settings,
]
));
}
/** /**
* @Route(path="", name="about", methods={"GET"}) * @Route(path="", name="about", methods={"GET"})
*
* @return \Symfony\Component\HttpFoundation\Response
*/ */
public function license() public function license(): Response
{ {
$filename = $this->projectDirectory . '/LICENSE'; $filename = $this->projectDirectory . '/LICENSE';
@@ -108,41 +53,4 @@ class AboutController extends AbstractController
'license' => $license 'license' => $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

@@ -10,9 +10,11 @@
namespace App\Controller; namespace App\Controller;
use App\Event\DashboardEvent; use App\Event\DashboardEvent;
use App\Widget\Type\CompoundChart; use App\Widget\Type\AbstractContainer;
use App\Widget\Type\AuthorizedWidget;
use App\Widget\Type\CompoundRow; use App\Widget\Type\CompoundRow;
use App\Widget\WidgetContainerInterface; use App\Widget\WidgetContainerInterface;
use App\Widget\WidgetException;
use App\Widget\WidgetService; use App\Widget\WidgetService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -67,11 +69,23 @@ class DashboardController extends AbstractController
continue; continue;
} }
// TODO this should be dynamic if (!isset($widgetRow['type'])) {
if ($widgetRow['type'] === 'compoundChart') { $widgetRow['type'] = CompoundRow::class;
$row = new CompoundChart(); }
} else {
$row = new CompoundRow(); if (!class_exists($widgetRow['type'])) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetRow['type']));
}
$row = new $widgetRow['type']();
if (!($row instanceof AbstractContainer)) {
throw new WidgetException(
sprintf(
'Expected widget type to be an instanceof "%s", but found "%s"',
AbstractContainer::class,
$widgetRow['type']
)
);
} }
$row->setTitle($widgetRow['title'] ?? ''); $row->setTitle($widgetRow['title'] ?? '');
@@ -82,7 +96,23 @@ class DashboardController extends AbstractController
throw new \Exception(sprintf('Unknown widget "%s"', $widgetName)); throw new \Exception(sprintf('Unknown widget "%s"', $widgetName));
} }
$row->addWidget($this->widgets->getWidget($widgetName)); $widget = $this->widgets->getWidget($widgetName);
$add = true;
if ($widget instanceof AuthorizedWidget) {
$tmp = false;
foreach ($widget->getPermissions() as $perm) {
if ($this->isGranted($perm)) {
$tmp = true;
break;
}
}
$add = $tmp;
}
if ($add) {
$row->addWidget($widget);
}
} }
$event->addSection($row); $event->addSection($row);

View File

@@ -0,0 +1,236 @@
<?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 PackageVersions\Versions;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route(path="/doctor")
* @Security("is_granted('system_information')")
*/
class DoctorController extends AbstractController
{
/**
* PHP extensions which Kimai needs for runtime.
* Some are not a hard requiremenet, but some functions might not work as expected.
*/
public const REQUIRED_EXTENSIONS = [
'intl',
'json',
'mbstring',
'pdo',
'zip',
'gd',
'xml'
];
/**
* Directories which need to be writable by the webserver.
*/
public const DIRECTORIES_WRITABLE = [
'var/cache/',
'var/data/',
'var/log/',
'var/sessions/',
'public/avatars/',
];
/**
* @var string
*/
protected $projectDirectory;
/**
* @param string $projectDirectory
*/
public function __construct(string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
/**
* @Route(path="", name="doctor", methods={"GET"})
*/
public function index(): Response
{
$logLines = 100;
return $this->render('doctor/index.html.twig', array_merge(
[
'modules' => get_loaded_extensions(),
'dotenv' => $this->getEnvVars(),
'info' => $this->getPhpInfo(),
'settings' => $this->getIniSettings(),
'extensions' => $this->getLoadedExtensions(),
'directories' => $this->getFilePermissions(),
'logs' => $this->getLog(),
'logLines' => $logLines,
'logSize' => $this->getLogSize(),
'composer' => Versions::VERSIONS,
]
));
}
private function getLoadedExtensions()
{
$results = [];
foreach (self::REQUIRED_EXTENSIONS as $extName) {
$results[$extName] = false;
if (extension_loaded($extName)) {
$results[$extName] = true;
}
}
return $results;
}
private function getLogSize()
{
$logfileName = 'var/log/' . getenv('APP_ENV') . '.log';
$logfile = $this->projectDirectory . '/' . $logfileName;
return filesize($logfile);
}
private function getLog(int $lines = 100)
{
if (!in_array(getenv('APP_ENV'), ['test', 'dev', 'prod'])) {
return [
'Unsupported log environment'
];
}
$logfileName = 'var/log/' . getenv('APP_ENV') . '.log';
$logfile = $this->projectDirectory . '/' . $logfileName;
if (!file_exists($logfile)) {
return [
'Could not find logfile: ' . $logfileName
];
}
$file = new \SplFileObject($logfile, 'r');
$file->seek($file->getSize());
$last_line = $file->key();
while ($last_line - $lines < 0) {
$lines--;
}
$lines = new \LimitIterator($file, $last_line - $lines, $last_line);
return iterator_to_array($lines);
}
private function getFilePermissions()
{
$results = [];
foreach (self::DIRECTORIES_WRITABLE as $path) {
$results[$path] = false;
$fullPath = $this->projectDirectory . '/' . $path;
$fullUri = realpath($fullPath);
if ($fullUri === false && !file_exists($fullPath)) {
@mkdir($fullPath);
$fullUri = realpath($fullPath);
}
if ($fullUri !== false && is_readable($fullUri) && is_writable($fullUri)) {
$results[$path] = true;
}
}
return $results;
}
private function getEnvVars()
{
return [
'APP_ENV' => getenv('APP_ENV'),
'MAILER_FROM' => getenv('MAILER_FROM'),
'CORS_ALLOW_ORIGIN' => getenv('CORS_ALLOW_ORIGIN'),
];
}
private function getIniSettings()
{
$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 $settings;
}
/**
* @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]);
}
}
}
$phpInfo = $phpinfo['phpinfo'];
unset($phpInfo[0]);
unset($phpInfo[1]);
return $phpInfo;
}
}

View File

@@ -83,7 +83,7 @@ class UserController extends AbstractController
} }
/* @var $entries Pagerfanta */ /* @var $entries Pagerfanta */
$entries = $this->getRepository()->findByQuery($query); $entries = $this->getRepository()->getPagerfantaForQuery($query);
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::USERS); $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::USERS);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);

View File

@@ -0,0 +1,140 @@
<?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\Project;
use App\Entity\Team;
use App\Entity\User;
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
*
* @codeCoverageIgnore
*/
class TeamFixtures extends Fixture implements DependentFixtureInterface
{
public const AMOUNT_TEAMS = 10;
public const MAX_USERS_PER_TEAM = 15;
public const MAX_PROJECTS_PER_TEAM = 5;
// lower batch size, as user preferences are added in the same run
public const BATCH_SIZE = 50;
/**
* @return array
*/
public function getDependencies()
{
return [
UserFixtures::class,
];
}
/**
* @param ObjectManager $manager
* @return User[]
*/
protected function getAllUsers(ObjectManager $manager)
{
$all = [];
/* @var User[] $entries */
$entries = $manager->getRepository(User::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* @param ObjectManager $manager
* @return Project[]
*/
protected function getAllProjects(ObjectManager $manager)
{
$all = [];
/* @var Project[] $entries */
$entries = $manager->getRepository(Project::class)->findAll();
foreach ($entries as $temp) {
$all[$temp->getId()] = $temp;
}
return $all;
}
/**
* {@inheritdoc}
*/
public function load(ObjectManager $manager)
{
$allUsers = $this->getAllUsers($manager);
$allProjects = $this->getAllProjects($manager);
$faker = Factory::create();
for ($i = 1; $i <= self::AMOUNT_TEAMS; $i++) {
$maxUsers = count($allUsers) - 1;
if (self::MAX_USERS_PER_TEAM < $maxUsers) {
$maxUsers = self::MAX_USERS_PER_TEAM;
}
$userCount = mt_rand(0, $maxUsers);
$maxProjects = count($allProjects) - 1;
if (self::MAX_PROJECTS_PER_TEAM < $maxProjects) {
$maxProjects = self::MAX_PROJECTS_PER_TEAM;
}
$projectCount = mt_rand(0, $maxProjects);
$team = new Team();
$team
->setName($faker->company . ' ' . $i)
->setTeamLead($allUsers[array_rand($allUsers)])
;
if ($userCount > 0) {
$userKeys = array_rand($allUsers, $userCount);
if (!is_array($userKeys)) {
$userKeys = [$userKeys];
}
foreach ($userKeys as $userKey) {
$team->addUser($allUsers[$userKey]);
}
}
if ($projectCount > 0) {
$projectKeys = array_rand($allProjects, $projectCount);
if (!is_array($projectKeys)) {
$projectKeys = [$projectKeys];
}
foreach ($projectKeys as $projectKey) {
$team->addProject($allProjects[$projectKey]);
}
}
$manager->persist($team);
if ($i % self::BATCH_SIZE === 0) {
$manager->flush();
$manager->clear(Team::class);
}
}
$manager->flush();
$manager->clear(Team::class);
}
}

View File

@@ -102,7 +102,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
$manager->persist($entry); $manager->persist($entry);
if ($i % self::BATCH_SIZE == 0) { if ($i % self::BATCH_SIZE === 0) {
$manager->flush(); $manager->flush();
$manager->clear(Timesheet::class); $manager->clear(Timesheet::class);
} }

View File

@@ -36,7 +36,7 @@ class UserFixtures extends Fixture
public const USERNAME_ADMIN = 'anna_admin'; public const USERNAME_ADMIN = 'anna_admin';
public const USERNAME_SUPER_ADMIN = 'susan_super'; public const USERNAME_SUPER_ADMIN = 'susan_super';
public const AMOUNT_EXTRA_USER = 2; public const AMOUNT_EXTRA_USER = 25;
public const MIN_RATE = 30; public const MIN_RATE = 30;
public const MAX_RATE = 120; public const MAX_RATE = 120;
@@ -158,7 +158,6 @@ class UserFixtures extends Fixture
->setUsername($username) ->setUsername($username)
->setEmail($email) ->setEmail($email)
->setRoles([User::ROLE_USER]) ->setRoles([User::ROLE_USER])
->setAvatar(self::DEFAULT_AVATAR)
->setEnabled(true) ->setEnabled(true)
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setPreferences($this->getUserPreferences($user)) ->setPreferences($this->getUserPreferences($user))

View File

@@ -12,6 +12,7 @@ namespace App\DependencyInjection;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\User; use App\Entity\User;
use App\Timesheet\Rounding\RoundingInterface; use App\Timesheet\Rounding\RoundingInterface;
use App\Widget\Type\CompoundRow;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface; use Symfony\Component\Config\Definition\ConfigurationInterface;
@@ -435,7 +436,7 @@ class Configuration implements ConfigurationInterface
->arrayPrototype() ->arrayPrototype()
->addDefaultsIfNotSet() ->addDefaultsIfNotSet()
->children() ->children()
->scalarNode('type')->defaultValue('simple')->end() ->scalarNode('type')->defaultValue(CompoundRow::class)->end()
->integerNode('order')->defaultValue(0)->end() ->integerNode('order')->defaultValue(0)->end()
->scalarNode('title')->end() ->scalarNode('title')->end()
->scalarNode('permission')->defaultNull()->end() ->scalarNode('permission')->defaultNull()->end()

View File

@@ -138,6 +138,14 @@ class Activity implements EntityWithMetaFields
return $this; return $this;
} }
public function isVisible(): bool
{
return $this->visible;
}
/**
* @deprecated since 1.4
*/
public function getVisible(): bool public function getVisible(): bool
{ {
return $this->visible; return $this->visible;

View File

@@ -247,6 +247,14 @@ class Customer implements EntityWithMetaFields
return $this; return $this;
} }
public function isVisible(): bool
{
return $this->visible;
}
/**
* @deprecated since 1.4
*/
public function getVisible(): bool public function getVisible(): bool
{ {
return $this->visible; return $this->visible;

View File

@@ -166,8 +166,13 @@ class Project implements EntityWithMetaFields
return $this; return $this;
} }
public function isVisible(): bool
{
return $this->visible;
}
/** /**
* @return bool * @deprecated since 1.4
*/ */
public function getVisible(): bool public function getVisible(): bool
{ {

View File

@@ -246,10 +246,10 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
} }
/** /**
* @param int $duration * @param int|null $duration
* @return Timesheet * @return Timesheet
*/ */
public function setDuration($duration): Timesheet public function setDuration(?int $duration): Timesheet
{ {
$this->duration = $duration; $this->duration = $duration;
@@ -259,9 +259,9 @@ class Timesheet implements EntityWithMetaFields, InvoiceItemInterface
/** /**
* Do not rely on the results of this method for running records. * Do not rely on the results of this method for running records.
* *
* @return int * @return int|null
*/ */
public function getDuration(): int public function getDuration(): ?int
{ {
return $this->duration; return $this->duration;
} }

View File

@@ -159,6 +159,12 @@ final class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $this->getIcon('configuration')) new MenuItemModel('system_configuration', 'menu.system_configuration', 'system_configuration', [], $this->getIcon('configuration'))
); );
} }
if ($auth->isGranted('system_information')) {
$menu->addChild(
new MenuItemModel('doctor', 'menu.doctor', 'doctor', [], $this->getIcon('doctor'))
);
}
} }
private function getIcon(string $icon) private function getIcon(string $icon)

View File

@@ -105,6 +105,9 @@ abstract class AbstractSpreadsheetRenderer
protected function setDuration(Worksheet $sheet, $column, $row, $duration) protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{ {
if (null === $duration) {
$duration = 0;
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration)); $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
} }

View File

@@ -45,10 +45,15 @@ trait RendererTrait
]; ];
} }
$duration = $timesheet->getDuration();
if (null === $duration) {
$duration = 0;
}
$summary[$id]['rate'] += $timesheet->getRate(); $summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $timesheet->getDuration(); $summary[$id]['duration'] += $duration;
$summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate(); $summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate();
$summary[$id]['activities'][$activityId]['duration'] += $timesheet->getDuration(); $summary[$id]['activities'][$activityId]['duration'] += $duration;
} }
asort($summary); asort($summary);

View File

@@ -54,6 +54,12 @@ class EnhancedChoiceTypeExtension extends AbstractTypeExtension
return; return;
} }
// expanded selects are rendered as checkboxes and using the selectpicker
// would display an empty dropdown
if (isset($options['expanded']) && true === $options['expanded']) {
return;
}
if (!isset($view->vars['attr'])) { if (!isset($view->vars['attr'])) {
$view->vars['attr'] = []; $view->vars['attr'] = [];
} }

View File

@@ -219,10 +219,10 @@ class TimesheetEditForm extends AbstractType
/** @var Project $project */ /** @var Project $project */
$project = $repo->find($project); $project = $repo->find($project);
if (null !== $project) { if (null !== $project) {
if (!$project->getCustomer()->getVisible()) { if (!$project->getCustomer()->isVisible()) {
$customer = null; $customer = null;
$project = null; $project = null;
} elseif (!$project->getVisible()) { } elseif (!$project->isVisible()) {
$project = null; $project = null;
} }
} }

View File

@@ -103,7 +103,7 @@ abstract class AbstractCalculator
{ {
$time = 0; $time = 0;
foreach ($this->model->getEntries() as $entry) { foreach ($this->model->getEntries() as $entry) {
if (null === $entry->getFixedRate()) { if (null === $entry->getFixedRate() && null !== $entry->getDuration()) {
$time += $entry->getDuration(); $time += $entry->getDuration();
} }
} }

View File

@@ -27,10 +27,15 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
protected function mergeInvoiceItems(InvoiceItem $invoiceItem, InvoiceItemInterface $entry) protected function mergeInvoiceItems(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
{ {
$duration = $invoiceItem->getDuration();
if (null !== $entry->getDuration()) {
$duration += $entry->getDuration();
}
$invoiceItem->setAmount($invoiceItem->getAmount() + 1); $invoiceItem->setAmount($invoiceItem->getAmount() + 1);
$invoiceItem->setUser($entry->getUser()); $invoiceItem->setUser($entry->getUser());
$invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate()); $invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate());
$invoiceItem->setDuration($invoiceItem->getDuration() + $entry->getDuration()); $invoiceItem->setDuration($duration);
if (null !== $entry->getFixedRate()) { if (null !== $entry->getFixedRate()) {
/* /*

View File

@@ -32,7 +32,7 @@ interface InvoiceItemInterface
public function getEnd(): ?\DateTime; public function getEnd(): ?\DateTime;
public function getDuration(): int; public function getDuration(): ?int;
public function getDescription(): ?string; public function getDescription(): ?string;

View File

@@ -9,12 +9,28 @@
namespace App\Model; namespace App\Model;
use App\Entity\Project;
class ProjectStatistic extends TimesheetCountedStatistic class ProjectStatistic extends TimesheetCountedStatistic
{ {
/**
* @var Project
*/
private $project;
/** /**
* @var int * @var int
*/ */
protected $activityAmount = 0; private $activityAmount = 0;
public function __construct(Project $project)
{
$this->project = $project;
}
public function getProject(): Project
{
return $this->project;
}
/** /**
* @return int * @return int

View File

@@ -13,6 +13,9 @@ use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class ActivityIdLoader implements LoaderInterface final class ActivityIdLoader implements LoaderInterface
{ {
/** /**

View File

@@ -12,6 +12,9 @@ namespace App\Repository\Loader;
use App\Entity\Customer; use App\Entity\Customer;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class CustomerIdLoader implements LoaderInterface final class CustomerIdLoader implements LoaderInterface
{ {
/** /**

View File

@@ -12,6 +12,9 @@ namespace App\Repository\Loader;
use App\Entity\Project; use App\Entity\Project;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class ProjectIdLoader implements LoaderInterface final class ProjectIdLoader implements LoaderInterface
{ {
/** /**

View File

@@ -12,6 +12,9 @@ namespace App\Repository\Loader;
use App\Entity\Team; use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class TeamIdLoader implements LoaderInterface final class TeamIdLoader implements LoaderInterface
{ {
/** /**

View File

@@ -15,6 +15,9 @@ use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class TimesheetIdLoader implements LoaderInterface final class TimesheetIdLoader implements LoaderInterface
{ {
/** /**

View File

@@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\Team;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*/
final class UserIdLoader implements LoaderInterface
{
/**
* @var EntityManagerInterface
*/
private $entityManager;
public function __construct(EntityManagerInterface $entityManager)
{
$this->entityManager = $entityManager;
}
/**
* @param int[] $ids
*/
public function loadResults(array $ids): void
{
if (empty($ids)) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var User[] $users */
$users = $qb->select('PARTIAL u.{id}', 'teams')
->from(User::class, 'u')
->leftJoin('u.teams', 'teams')
->andWhere($qb->expr()->in('u.id', $ids))
->getQuery()
->execute();
$teamIds = [];
foreach ($users as $user) {
foreach ($user->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
if (count($teamIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'teamlead')
->from(Team::class, 't')
->leftJoin('t.teamlead', 'teamlead')
->andWhere($qb->expr()->in('t.id', $teamIds))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'users')
->from(Team::class, 't')
->leftJoin('t.users', 'users')
->andWhere($qb->expr()->in('t.id', $teamIds))
->getQuery()
->execute();
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
final class UserLoader implements LoaderInterface
{
/**
* @var UserIdLoader
*/
private $loader;
public function __construct(EntityManagerInterface $entityManager)
{
$this->loader = new UserIdLoader($entityManager);
}
/**
* @param User[] $users
*/
public function loadResults(array $users): void
{
$ids = array_map(function (User $user) {
return $user->getId();
}, $users);
$this->loader->loadResults($ids);
}
}

View File

@@ -74,7 +74,7 @@ class ProjectRepository extends EntityRepository
public function getProjectStatistics(Project $project): ProjectStatistic public function getProjectStatistics(Project $project): ProjectStatistic
{ {
$stats = new ProjectStatistic(); $stats = new ProjectStatistic($project);
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();

View File

@@ -25,8 +25,17 @@ class BaseQuery
public const DEFAULT_PAGESIZE = 50; public const DEFAULT_PAGESIZE = 50;
public const DEFAULT_PAGE = 1; public const DEFAULT_PAGE = 1;
/**
* @deprecated since 1.4, will be removed with 1.6
*/
public const RESULT_TYPE_OBJECTS = 'Objects'; public const RESULT_TYPE_OBJECTS = 'Objects';
/**
* @deprecated since 1.4, will be removed with 1.6
*/
public const RESULT_TYPE_PAGER = 'PagerFanta'; public const RESULT_TYPE_PAGER = 'PagerFanta';
/**
* @deprecated since 1.4, will be removed with 1.6
*/
public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder'; public const RESULT_TYPE_QUERYBUILDER = 'QueryBuilder';
private $defaults = [ private $defaults = [
@@ -54,6 +63,7 @@ class BaseQuery
private $order = self::ORDER_ASC; private $order = self::ORDER_ASC;
/** /**
* @var string * @var string
* @deprecated since 1.4, will be removed with 1.6
*/ */
private $resultType = self::RESULT_TYPE_PAGER; private $resultType = self::RESULT_TYPE_PAGER;
/** /**
@@ -179,28 +189,11 @@ class BaseQuery
*/ */
public function getResultType() public function getResultType()
{ {
@trigger_error('BaseQuery::getResultType() is deprecated and will be removed with 1.6', E_USER_DEPRECATED);
return $this->resultType; return $this->resultType;
} }
/**
* @deprecated since 1.0
* @param string $resultType
* @return $this
* @throws \InvalidArgumentException
*/
public function setResultType(string $resultType)
{
$allowed = [self::RESULT_TYPE_PAGER, self::RESULT_TYPE_QUERYBUILDER, self::RESULT_TYPE_OBJECTS];
if (!in_array($resultType, $allowed)) {
throw new \InvalidArgumentException('Unsupported query result type');
}
$this->resultType = $resultType;
return $this;
}
public function hasSearchTerm(): bool public function hasSearchTerm(): bool
{ {
return null !== $this->searchTerm; return null !== $this->searchTerm;

View File

@@ -1,54 +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\Repository;
use App\Repository\Query\BaseQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
/**
* @deprecated since 1.0
*/
trait RepositoryTrait
{
/**
* @deprecated since 1.0
* @param QueryBuilder $qb
* @param BaseQuery $query
* @return QueryBuilder|Pagerfanta|array
*/
protected function getBaseQueryResult(QueryBuilder $qb, BaseQuery $query)
{
if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) {
return $this->getPager($qb->getQuery(), $query->getPage(), $query->getPageSize());
} elseif (BaseQuery::RESULT_TYPE_OBJECTS === $query->getResultType()) {
return $qb->getQuery()->execute();
}
return $qb;
}
/**
* @param Query $query
* @param int $page
* @param int $maxPerPage
* @return Pagerfanta
*/
private function getPager(Query $query, $page = 1, $maxPerPage = 25)
{
$paginator = new Pagerfanta(new DoctrineORMAdapter($query, false));
$paginator->setMaxPerPage($maxPerPage);
$paginator->setCurrentPage($page);
return $paginator;
}
}

View File

@@ -341,7 +341,10 @@ class TimesheetRepository extends EntityRepository
} }
$duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp(); $duration = $newDateBegin->getTimestamp() - $beginTmp->getTimestamp();
$durationPercent = $duration / $result->getDuration(); $durationPercent = 0;
if ($result->getDuration() !== null && $result->getDuration() > 0) {
$durationPercent = $duration / $result->getDuration();
}
$rate = $result->getRate() * $durationPercent; $rate = $result->getRate() * $durationPercent;
$results[$dateKey]['rate'] += $rate; $results[$dateKey]['rate'] += $rate;
@@ -605,7 +608,9 @@ class TimesheetRepository extends EntityRepository
if (!empty($query->getTeams())) { if (!empty($query->getTeams())) {
foreach ($query->getTeams() as $team) { foreach ($query->getTeams() as $team) {
$user = array_merge($user, $team->getUsers()->toArray()); foreach ($team->getUsers() as $teamUser) {
$user[] = $teamUser;
}
} }
} }

View File

@@ -10,16 +10,20 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Repository\Loader\UserLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\UserFormTypeQuery; use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery; use App\Repository\Query\UserQuery;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Pagerfanta\Adapter\DoctrineORMAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
class UserRepository extends EntityRepository implements UserLoaderInterface class UserRepository extends EntityRepository implements UserLoaderInterface
{ {
use RepositoryTrait;
public function getById($id): ?User public function getById($id): ?User
{ {
@trigger_error('UserRepository::getById is deprecated and will be removed with 2.0', E_USER_DEPRECATED); @trigger_error('UserRepository::getById is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
@@ -81,8 +85,84 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
/** /**
* @param UserQuery $query * @param UserQuery $query
* @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta * @return array|\Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta
* @deprecated since 1.4, use getUsersForQuery() instead
*/ */
public function findByQuery(UserQuery $query) public function findByQuery(UserQuery $query)
{
@trigger_error('UserRepository::findByQuery() is deprecated and will be removed with 1.6', E_USER_DEPRECATED);
$qb = $this->getQueryBuilderForQuery($query);
if (BaseQuery::RESULT_TYPE_PAGER === $query->getResultType()) {
$paginator = new Pagerfanta(new DoctrineORMAdapter($qb->getQuery(), false));
$paginator->setMaxPerPage($query->getPageSize());
$paginator->setCurrentPage($query->getPage());
return $paginator;
}
if (BaseQuery::RESULT_TYPE_OBJECTS === $query->getResultType()) {
return $qb->getQuery()->execute();
}
return $qb;
}
/**
* @param string $username
* @return mixed|null|\Symfony\Component\Security\Core\User\UserInterface
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function loadUserByUsername($username)
{
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)
->getQuery()
->getSingleResult();
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('u');
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->orderBy('u.username', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb;
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
return;
}
// make sure that admins see all user
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
return;
}
if (null !== $user) {
$qb->leftJoin('u.teams', 'teams')
->leftJoin('teams.users', 'users')
->andWhere('teams.teamlead = :id')
->setParameter('id', $user);
}
}
private function getQueryBuilderForQuery(UserQuery $query): QueryBuilder
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
@@ -144,61 +224,55 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
} }
} }
return $this->getBaseQueryResult($qb, $query);
}
/**
* @param string $username
* @return mixed|null|\Symfony\Component\Security\Core\User\UserInterface
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function loadUserByUsername($username)
{
return $this->createQueryBuilder('u')
->select('u', 'p', 't', 'tu', 'tl')
->leftJoin('u.preferences', 'p')
->leftJoin('u.teams', 't')
->leftJoin('t.users', 'tu')
->leftJoin('t.teamlead', 'tl')
->where('u.username = :username')
->orWhere('u.email = :username')
->setParameter('username', $username)
->getQuery()
->getSingleResult();
}
public function getQueryBuilderForFormType(UserFormTypeQuery $query): QueryBuilder
{
$qb = $this->createQueryBuilder('u');
$qb->andWhere($qb->expr()->eq('u.enabled', ':enabled'));
$qb->setParameter('enabled', true, \PDO::PARAM_BOOL);
$qb->orderBy('u.username', 'ASC');
$this->addPermissionCriteria($qb, $query->getUser(), $query->getTeams());
return $qb; return $qb;
} }
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []) public function getPagerfantaForQuery(UserQuery $query): Pagerfanta
{ {
// make sure that all queries without a user see all user $paginator = new Pagerfanta($this->getPaginatorForQuery($query));
if (null === $user && empty($teams)) { $paginator->setMaxPerPage($query->getPageSize());
return; $paginator->setCurrentPage($query->getPage());
}
// make sure that admins see all user return $paginator;
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) { }
return;
}
if (null !== $user) { protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface
$qb->leftJoin('u.teams', 'teams') {
->leftJoin('teams.users', 'users') $qb = $this->getQueryBuilderForQuery($query);
->andWhere('teams.teamlead = :id') $qb
->setParameter('id', $user); ->resetDQLPart('select')
} ->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('u.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter);
}
/**
* @param UserQuery $query
* @return User[]
*/
public function getUsersForQuery(UserQuery $query): iterable
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb);
}
/**
* @param QueryBuilder $qb
* @return User[]
*/
protected function getHydratedResultsByQuery(QueryBuilder $qb): iterable
{
$results = $qb->getQuery()->getResult();
$loader = new UserLoader($qb->getEntityManager());
$loader->loadResults($results);
return $results;
} }
} }

View File

@@ -12,6 +12,8 @@ namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Security\CurrentUser; use App\Security\CurrentUser;
use App\Widget\Type\AbstractWidgetType; use App\Widget\Type\AbstractWidgetType;
use App\Widget\Type\Counter;
use App\Widget\Type\YearChart;
use App\Widget\WidgetException; use App\Widget\WidgetException;
use App\Widget\WidgetInterface; use App\Widget\WidgetInterface;
@@ -79,6 +81,12 @@ class WidgetRepository
return $this->widgets[$id]; return $this->widgets[$id];
} }
/**
* @param string $name
* @param array $widget
* @return WidgetInterface
* @throws WidgetException
*/
protected function create(string $name, array $widget): WidgetInterface protected function create(string $name, array $widget): WidgetInterface
{ {
$user = $this->user; $user = $this->user;
@@ -88,23 +96,33 @@ class WidgetRepository
if (!isset($widget['type'])) { if (!isset($widget['type'])) {
@trigger_error('Using a widget definition without a "type" is deprecated', E_USER_DEPRECATED); @trigger_error('Using a widget definition without a "type" is deprecated', E_USER_DEPRECATED);
$widget['type'] = 'counter'; $widget['type'] = Counter::class;
} }
$widgetClassName = ucfirst($widget['type']);
$widgetClassName = '\\App\\Widget\\Type\\' . ucfirst($widget['type']);
if (!class_exists($widgetClassName)) { if (!class_exists($widgetClassName)) {
throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetClassName)); throw new WidgetException(sprintf('Unknown widget type "%s"', $widgetClassName));
} }
$model = new \ReflectionClass($widgetClassName);
if (!$model->isSubclassOf(AbstractWidgetType::class)) {
throw new WidgetException(sprintf('Invalid widget type "%s" does not extend AbstractWidgetType', $widgetClassName));
}
$data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser);
/** @var AbstractWidgetType $model */ /** @var AbstractWidgetType $model */
$model = new $widgetClassName(); $model = new $widgetClassName();
if (!($model instanceof AbstractWidgetType)) {
throw new WidgetException(
sprintf(
'Widget type "%s" is not an instance of "%s"',
$widgetClassName,
AbstractWidgetType::class
)
);
}
try {
$data = $this->repository->getStatistic($widget['query'], $begin, $end, $theUser);
} catch (\Exception $ex) {
throw new WidgetException(
'Failed loading widget data: ' . $ex->getMessage()
);
}
$model $model
->setId($name) ->setId($name)
->setTitle($widget['title']) ->setTitle($widget['title'])
@@ -140,7 +158,7 @@ class WidgetRepository
'end' => '23:59:59', 'end' => '23:59:59',
'icon' => 'duration', 'icon' => 'duration',
'color' => 'green', 'color' => 'green',
'type' => 'counter' 'type' => Counter::class,
], ],
'userDurationWeek' => [ 'userDurationWeek' => [
'title' => 'stats.durationWeek', 'title' => 'stats.durationWeek',
@@ -150,7 +168,7 @@ class WidgetRepository
'end' => 'sunday this week 23:59:59', 'end' => 'sunday this week 23:59:59',
'icon' => 'duration', 'icon' => 'duration',
'color' => 'blue', 'color' => 'blue',
'type' => 'counter' 'type' => Counter::class,
], ],
'userDurationMonth' => [ 'userDurationMonth' => [
'title' => 'stats.durationMonth', 'title' => 'stats.durationMonth',
@@ -160,7 +178,7 @@ class WidgetRepository
'end' => 'last day of this month 23:59:59', 'end' => 'last day of this month 23:59:59',
'icon' => 'duration', 'icon' => 'duration',
'color' => 'purple', 'color' => 'purple',
'type' => 'counter' 'type' => Counter::class,
], ],
'userDurationYear' => [ 'userDurationYear' => [
'title' => 'stats.durationYear', 'title' => 'stats.durationYear',
@@ -170,7 +188,7 @@ class WidgetRepository
'end' => '31 december this year 23:59:59', 'end' => '31 december this year 23:59:59',
'icon' => 'duration', 'icon' => 'duration',
'color' => 'yellow', 'color' => 'yellow',
'type' => 'counter' 'type' => Counter::class,
], ],
'userDurationTotal' => [ 'userDurationTotal' => [
'title' => 'stats.durationTotal', 'title' => 'stats.durationTotal',
@@ -178,7 +196,7 @@ class WidgetRepository
'user' => true, 'user' => true,
'icon' => 'duration', 'icon' => 'duration',
'color' => 'red', 'color' => 'red',
'type' => 'counter' 'type' => Counter::class,
], ],
'userAmountToday' => [ 'userAmountToday' => [
'title' => 'stats.amountToday', 'title' => 'stats.amountToday',
@@ -188,7 +206,7 @@ class WidgetRepository
'end' => '23:59:59', 'end' => '23:59:59',
'icon' => 'money', 'icon' => 'money',
'color' => 'green', 'color' => 'green',
'type' => 'counter' 'type' => Counter::class,
], ],
'userAmountWeek' => [ 'userAmountWeek' => [
'title' => 'stats.amountWeek', 'title' => 'stats.amountWeek',
@@ -198,7 +216,7 @@ class WidgetRepository
'end' => 'sunday this week 23:59:59', 'end' => 'sunday this week 23:59:59',
'icon' => 'money', 'icon' => 'money',
'color' => 'blue', 'color' => 'blue',
'type' => 'counter' 'type' => Counter::class,
], ],
'userAmountMonth' => [ 'userAmountMonth' => [
'title' => 'stats.amountMonth', 'title' => 'stats.amountMonth',
@@ -208,7 +226,7 @@ class WidgetRepository
'end' => 'last day of this month 23:59:59', 'end' => 'last day of this month 23:59:59',
'icon' => 'money', 'icon' => 'money',
'color' => 'purple', 'color' => 'purple',
'type' => 'counter' 'type' => Counter::class,
], ],
'userAmountYear' => [ 'userAmountYear' => [
'title' => 'stats.amountYear', 'title' => 'stats.amountYear',
@@ -218,7 +236,7 @@ class WidgetRepository
'end' => '31 december this year 23:59:59', 'end' => '31 december this year 23:59:59',
'icon' => 'money', 'icon' => 'money',
'color' => 'yellow', 'color' => 'yellow',
'type' => 'counter' 'type' => Counter::class,
], ],
'userAmountTotal' => [ 'userAmountTotal' => [
'title' => 'stats.amountTotal', 'title' => 'stats.amountTotal',
@@ -226,7 +244,7 @@ class WidgetRepository
'user' => true, 'user' => true,
'icon' => 'money', 'icon' => 'money',
'color' => 'red', 'color' => 'red',
'type' => 'counter' 'type' => Counter::class,
], ],
'durationToday' => [ 'durationToday' => [
'title' => 'stats.durationToday', 'title' => 'stats.durationToday',
@@ -236,7 +254,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'green', 'color' => 'green',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'durationWeek' => [ 'durationWeek' => [
'title' => 'stats.durationWeek', 'title' => 'stats.durationWeek',
@@ -246,7 +264,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'blue', 'color' => 'blue',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'durationMonth' => [ 'durationMonth' => [
'title' => 'stats.durationMonth', 'title' => 'stats.durationMonth',
@@ -256,7 +274,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'purple', 'color' => 'purple',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'durationYear' => [ 'durationYear' => [
'title' => 'stats.durationYear', 'title' => 'stats.durationYear',
@@ -266,7 +284,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'yellow', 'color' => 'yellow',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'durationTotal' => [ 'durationTotal' => [
'title' => 'stats.durationTotal', 'title' => 'stats.durationTotal',
@@ -274,7 +292,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'red', 'color' => 'red',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'amountToday' => [ 'amountToday' => [
'title' => 'stats.amountToday', 'title' => 'stats.amountToday',
@@ -284,7 +302,7 @@ class WidgetRepository
'icon' => 'money', 'icon' => 'money',
'color' => 'green', 'color' => 'green',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'amountWeek' => [ 'amountWeek' => [
'title' => 'stats.amountWeek', 'title' => 'stats.amountWeek',
@@ -294,7 +312,7 @@ class WidgetRepository
'icon' => 'money', 'icon' => 'money',
'color' => 'blue', 'color' => 'blue',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'amountMonth' => [ 'amountMonth' => [
'title' => 'stats.amountMonth', 'title' => 'stats.amountMonth',
@@ -304,7 +322,7 @@ class WidgetRepository
'icon' => 'money', 'icon' => 'money',
'color' => 'purple', 'color' => 'purple',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'amountYear' => [ 'amountYear' => [
'title' => 'stats.amountYear', 'title' => 'stats.amountYear',
@@ -314,7 +332,7 @@ class WidgetRepository
'icon' => 'money', 'icon' => 'money',
'color' => 'yellow', 'color' => 'yellow',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'amountTotal' => [ 'amountTotal' => [
'title' => 'stats.amountTotal', 'title' => 'stats.amountTotal',
@@ -322,7 +340,7 @@ class WidgetRepository
'icon' => 'money', 'icon' => 'money',
'color' => 'red', 'color' => 'red',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeUsersToday' => [ 'activeUsersToday' => [
'title' => 'stats.userActiveToday', 'title' => 'stats.userActiveToday',
@@ -332,7 +350,7 @@ class WidgetRepository
'icon' => 'user', 'icon' => 'user',
'color' => 'green', 'color' => 'green',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeUsersWeek' => [ 'activeUsersWeek' => [
'title' => 'stats.userActiveWeek', 'title' => 'stats.userActiveWeek',
@@ -342,7 +360,7 @@ class WidgetRepository
'icon' => 'user', 'icon' => 'user',
'color' => 'blue', 'color' => 'blue',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeUsersMonth' => [ 'activeUsersMonth' => [
'title' => 'stats.userActiveMonth', 'title' => 'stats.userActiveMonth',
@@ -352,7 +370,7 @@ class WidgetRepository
'icon' => 'user', 'icon' => 'user',
'color' => 'purple', 'color' => 'purple',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeUsersYear' => [ 'activeUsersYear' => [
'title' => 'stats.userActiveYear', 'title' => 'stats.userActiveYear',
@@ -362,7 +380,7 @@ class WidgetRepository
'icon' => 'user', 'icon' => 'user',
'color' => 'yellow', 'color' => 'yellow',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeUsersTotal' => [ 'activeUsersTotal' => [
'title' => 'stats.userActiveTotal', 'title' => 'stats.userActiveTotal',
@@ -370,7 +388,7 @@ class WidgetRepository
'icon' => 'user', 'icon' => 'user',
'color' => 'red', 'color' => 'red',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'activeRecordings' => [ 'activeRecordings' => [
'title' => 'stats.activeRecordings', 'title' => 'stats.activeRecordings',
@@ -378,7 +396,7 @@ class WidgetRepository
'icon' => 'duration', 'icon' => 'duration',
'color' => 'red', 'color' => 'red',
'user' => false, 'user' => false,
'type' => 'counter' 'type' => Counter::class,
], ],
'userRecapThisYear' => [ 'userRecapThisYear' => [
'title' => 'stats.yourWorkingHours', 'title' => 'stats.yourWorkingHours',
@@ -388,7 +406,7 @@ class WidgetRepository
'end' => '31 december this year 23:59:59', 'end' => '31 december this year 23:59:59',
'color' => '', 'color' => '',
'icon' => '', 'icon' => '',
'type' => 'yearChart' 'type' => YearChart::class,
], ],
'userRecapLastYear' => [ 'userRecapLastYear' => [
'title' => 'stats.yourWorkingHours', 'title' => 'stats.yourWorkingHours',
@@ -398,7 +416,7 @@ class WidgetRepository
'end' => '31 december last year 23:59:59', 'end' => '31 december last year 23:59:59',
'color' => 'rgba(0,115,183,0.7)|#3b8bba', 'color' => 'rgba(0,115,183,0.7)|#3b8bba',
'icon' => '', 'icon' => '',
'type' => 'yearChart' 'type' => YearChart::class,
], ],
'userRecapTwoYears' => [ 'userRecapTwoYears' => [
'title' => 'stats.yourWorkingHours', 'title' => 'stats.yourWorkingHours',
@@ -408,7 +426,7 @@ class WidgetRepository
'end' => '31 december this year 23:59:59', 'end' => '31 december this year 23:59:59',
'color' => 'rgba(0,115,183,0.6)|#3b8bba;rgba(233,233,233,0.8)|#ccc', 'color' => 'rgba(0,115,183,0.6)|#3b8bba;rgba(233,233,233,0.8)|#ccc',
'icon' => '', 'icon' => '',
'type' => 'yearChart' 'type' => YearChart::class,
], ],
'userRecapThreeYears' => [ 'userRecapThreeYears' => [
'title' => 'stats.yourWorkingHours', 'title' => 'stats.yourWorkingHours',
@@ -418,7 +436,7 @@ class WidgetRepository
'end' => 'this year last day of december 23:59:59', 'end' => 'this year last day of december 23:59:59',
'color' => 'rgba(0,115,183,0.4)|#3b8bba;rgba(233,233,233,0.7)|#ccc;rgba(210,214,222,0.9)|#c1c7d1', 'color' => 'rgba(0,115,183,0.4)|#3b8bba;rgba(233,233,233,0.7)|#ccc;rgba(210,214,222,0.9)|#c1c7d1',
'icon' => '', 'icon' => '',
'type' => 'yearChart' 'type' => YearChart::class,
], ],
]; ];
} }

View File

@@ -56,7 +56,10 @@ class RateCalculator implements CalculatorInterface
$factor = $this->getRateFactor($record); $factor = $this->getRateFactor($record);
$hourlyRate = (float) ($hourlyRate * $factor); $hourlyRate = (float) ($hourlyRate * $factor);
$rate = Util::calculateRate($hourlyRate, $record->getDuration()); $rate = 0;
if (null !== $record->getDuration()) {
$rate = Util::calculateRate($hourlyRate, $record->getDuration());
}
$record->setHourlyRate($hourlyRate); $record->setHourlyRate($hourlyRate);
$record->setRate($rate); $record->setRate($rate);

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\Twig;
use App\Entity\User;
use App\Utils\AvatarService;
use Symfony\Component\Asset\Packages;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class AvatarExtension extends AbstractExtension
{
/**
* @var AvatarService
*/
private $avatar;
/**
* @var Packages
*/
private $packages;
public function __construct(AvatarService $avatar, Packages $packages)
{
$this->avatar = $avatar;
$this->packages = $packages;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('avatar', [$this, 'getAvatarUrl']),
];
}
public function getAvatarUrl(?User $profile, string $default): string
{
if (null === $profile) {
return $this->packages->getUrl($default);
}
$url = $this->avatar->getAvatar($profile);
if (null === $url) {
return $this->packages->getUrl($default);
}
return $this->packages->getUrl($url);
}
}

View File

@@ -135,12 +135,11 @@ class Extensions extends AbstractExtension
} }
if ($duration instanceof Timesheet) { if ($duration instanceof Timesheet) {
$seconds = $duration->getDuration();
if (null === $duration->getEnd()) { if (null === $duration->getEnd()) {
$seconds = time() - $duration->getBegin()->getTimestamp(); $duration = time() - $duration->getBegin()->getTimestamp();
} else {
$duration = $duration->getDuration();
} }
$duration = $seconds;
} }
return (int) $duration; return (int) $duration;

View File

@@ -75,6 +75,8 @@ final class IconExtension extends AbstractExtension
'configuration' => 'fas fa-cogs', 'configuration' => 'fas fa-cogs',
'mail-sent' => 'fas fa-paper-plane', 'mail-sent' => 'fas fa-paper-plane',
'mail' => 'fas fa-envelope-open', 'mail' => 'fas fa-envelope-open',
'doctor' => 'fas fa-medkit',
'success' => 'fas fa-check',
]; ];
/** /**

155
src/Utils/AvatarService.php Normal file
View File

@@ -0,0 +1,155 @@
<?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\Utils;
use App\Entity\User;
use Laravolt\Avatar\Avatar;
use Laravolt\Avatar\Generator\DefaultGenerator;
class AvatarService
{
/**
* @var string
*/
private $projectDirectory;
public const AVATAR_CONFIG = [
'driver' => 'gd',
'generator' => DefaultGenerator::class,
'ascii' => true,
'shape' => 'circle',
'width' => 100,
'height' => 100,
'chars' => 2,
'fontSize' => 44,
'fontFamily' => null,
'uppercase' => true,
//'fonts' => ['path/to/OpenSans-Bold.ttf', 'path/to/rockwell.ttf'],
'foregrounds' => [
'#FFFFFF'
],
'backgrounds' => [
'#f44336',
'#E91E63',
'#9C27B0',
'#673AB7',
'#3F51B5',
'#2196F3',
'#03A9F4',
'#00BCD4',
'#009688',
'#4CAF50',
'#8BC34A',
'#CDDC39',
'#FFC107',
'#FF9800',
'#FF5722',
],
'border' => [
'size' => 1,
'color' => 'background'
],
'theme' => '*',
'themes' => [
/*
'grayscale-light' => [
'backgrounds' => ['#edf2f7', '#e2e8f0', '#cbd5e0'],
'foregrounds' => ['#a0aec0'],
],
'grayscale-dark' => [
'backgrounds' => ['#2d3748', '#4a5568', '#718096'],
'foregrounds' => ['#e2e8f0'],
],
*/
'colorful' => [
'backgrounds' => [
'#a972c9',
'#9C27B0',
'#673AB7',
'#5319e7',
'#041fd1',
'#3F51B5',
'#2196F3',
'#03A9F4',
'#00BCD4',
'#006b75',
'#009688',
'#00bb32',
'#4CAF50',
'#8BC34A',
'#CDDC39',
'#FFC107',
'#FF9800',
'#FF5722',
'#f41a00',
'#E91E63',
'#b60205',
'#cc317c',
'#d82d80',
'#e135f4',
'#2d3748',
'#4a5568',
'#718096',
],
'foregrounds' => ['#FFFFFF'],
],
]
];
public function __construct(string $projectDirectory)
{
$this->projectDirectory = $projectDirectory;
}
private function getAvatarUrl(User $profile): string
{
return '/avatars/' . md5($profile->getId() . '_' . $profile->getDisplayName()) . '.png';
}
private function getImagePath(User $profile): string
{
$avatarPath = realpath($this->projectDirectory . '/public/');
return $avatarPath . $this->getAvatarUrl($profile);
}
public function generateAvatar(User $profile, bool $regenerate = false): bool
{
if (!extension_loaded('gd')) {
return false;
}
$filePath = $this->getImagePath($profile);
if ($regenerate || !file_exists($filePath)) {
if (!is_writable(dirname($filePath))) {
return false;
}
$avatar = new Avatar(self::AVATAR_CONFIG);
$avatar->create($profile->getDisplayName())->save($filePath, 90);
$avatar->create($profile->getDisplayName())->save($filePath, 90);
}
return true;
}
public function getAvatar(User $profile): ?string
{
if (!empty(trim($profile->getAvatar()))) {
return $profile->getAvatar();
}
if (!$this->generateAvatar($profile)) {
return null;
}
return $this->getAvatarUrl($profile);
}
}

View File

@@ -162,7 +162,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && $activity->getVisible() === false) { if (null === $timesheet->getEnd() && !$activity->isVisible()) {
$context->buildViolation('Cannot start a disabled activity.') $context->buildViolation('Cannot start a disabled activity.')
->atPath('activity') ->atPath('activity')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
@@ -170,7 +170,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && $project->getVisible() === false) { if (null === $timesheet->getEnd() && !$project->isVisible()) {
$context->buildViolation('Cannot start a disabled project.') $context->buildViolation('Cannot start a disabled project.')
->atPath('project') ->atPath('project')
->setTranslationDomain('validators') ->setTranslationDomain('validators')
@@ -178,7 +178,7 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (null === $timesheet->getEnd() && $project->getCustomer()->getVisible() === false) { if (null === $timesheet->getEnd() && !$project->getCustomer()->isVisible()) {
$context->buildViolation('Cannot start a disabled customer.') $context->buildViolation('Cannot start a disabled customer.')
->atPath('customer') ->atPath('customer')
->setTranslationDomain('validators') ->setTranslationDomain('validators')

View File

@@ -140,11 +140,11 @@ class TimesheetVoter extends AbstractVoter
return false; return false;
} }
if (!$timesheet->getActivity()->getVisible() || !$timesheet->getProject()->getVisible()) { if (!$timesheet->getActivity()->isVisible() || !$timesheet->getProject()->isVisible()) {
return false; return false;
} }
if (!$timesheet->getProject()->getCustomer()->getVisible()) { if (!$timesheet->getProject()->getCustomer()->isVisible()) {
return false; return false;
} }

View File

@@ -0,0 +1,21 @@
<?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\Widget\Type;
interface AuthorizedWidget
{
/**
* Return a list of granted syntax string.
* If ANY of the given permission strings matches, access is granted.
*
* @return string[]
*/
public function getPermissions(): array;
}

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\Widget\Type;
use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\ProjectRepository;
use App\Security\CurrentUser;
class UserTeamProjects extends SimpleWidget implements AuthorizedWidget
{
/**
* @var ProjectRepository
*/
private $repository;
public function __construct(CurrentUser $user, ProjectRepository $repository)
{
$this->setId('UserTeamProjects');
$this->setTitle('label.my_team_projects');
$this->setOptions([
'user' => $user->getUser(),
'id' => '',
]);
$this->repository = $repository;
}
public function getOptions(array $options = []): array
{
$options = parent::getOptions($options);
if (empty($options['id'])) {
$options['id'] = uniqid('UserTeamProjects_');
}
return $options;
}
public function getData(array $options = [])
{
$options = $this->getOptions($options);
/** @var User $user */
$user = $options['user'];
$projects = [];
/** @var Team $team */
foreach ($user->getTeams() as $team) {
/** @var Project $project */
foreach ($team->getProjects() as $project) {
if (!$project->isVisible() || !$project->getCustomer()->isVisible()) {
continue;
}
$projects[$project->getId()] = $project;
}
}
$stats = [];
foreach ($projects as $id => $project) {
if ($project->getBudget() > 0 || $project->getTimeBudget() > 0) {
$stats[] = $this->repository->getProjectStatistics($project);
}
}
return $stats;
}
/**
* @return string[]
*/
public function getPermissions(): array
{
return ['budget_team_project', 'budget_teamlead_project', 'budget_project'];
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Widget\Type;
use App\Entity\User;
use App\Security\CurrentUser;
class UserTeams extends SimpleWidget implements AuthorizedWidget
{
public function __construct(CurrentUser $user)
{
$this->setId('UserTeams');
$this->setTitle('label.teams');
$this->setOptions([
'user' => $user->getUser(),
'id' => '',
]);
}
public function getOptions(array $options = []): array
{
$options = parent::getOptions($options);
if (empty($options['id'])) {
$options['id'] = uniqid('UserTeams_');
}
return $options;
}
public function getData(array $options = [])
{
$options = $this->getOptions($options);
/** @var User $user */
$user = $options['user'];
return $user->getTeams();
}
/**
* @return string[]
*/
public function getPermissions(): array
{
return ['view_team_member', 'view_team'];
}
}

View File

@@ -147,6 +147,9 @@
"gedmo/doctrine-extensions": { "gedmo/doctrine-extensions": {
"version": "v2.4.36" "version": "v2.4.36"
}, },
"guzzlehttp/psr7": {
"version": "1.6.1"
},
"hoa/compiler": { "hoa/compiler": {
"version": "3.17.08.08" "version": "3.17.08.08"
}, },
@@ -186,6 +189,18 @@
"hoa/zformat": { "hoa/zformat": {
"version": "1.17.01.10" "version": "1.17.01.10"
}, },
"illuminate/cache": {
"version": "v6.0.4"
},
"illuminate/contracts": {
"version": "v6.0.4"
},
"illuminate/support": {
"version": "v6.0.4"
},
"intervention/image": {
"version": "2.5.0"
},
"jdorn/sql-formatter": { "jdorn/sql-formatter": {
"version": "v1.2.17" "version": "v1.2.17"
}, },
@@ -213,6 +228,9 @@
"kimai/kimai2-composer": { "kimai/kimai2-composer": {
"version": "0.1" "version": "0.1"
}, },
"laravolt/avatar": {
"version": "3.0.0"
},
"markbaker/complex": { "markbaker/complex": {
"version": "1.4.6" "version": "1.4.6"
}, },
@@ -246,6 +264,9 @@
"ref": "f0436fc35fca88eada758311f8de43bfb61f1980" "ref": "f0436fc35fca88eada758311f8de43bfb61f1980"
} }
}, },
"nesbot/carbon": {
"version": "2.24.0"
},
"nette/bootstrap": { "nette/bootstrap": {
"version": "v3.0.0" "version": "v3.0.0"
}, },
@@ -363,12 +384,18 @@
"psr/container": { "psr/container": {
"version": "1.0.0" "version": "1.0.0"
}, },
"psr/http-message": {
"version": "1.0.1"
},
"psr/log": { "psr/log": {
"version": "1.0.2" "version": "1.0.2"
}, },
"psr/simple-cache": { "psr/simple-cache": {
"version": "1.0.0" "version": "1.0.0"
}, },
"ralouphie/getallheaders": {
"version": "3.0.3"
},
"sebastian/code-unit-reverse-lookup": { "sebastian/code-unit-reverse-lookup": {
"version": "1.0.1" "version": "1.0.1"
}, },

View File

@@ -0,0 +1,6 @@
{% macro about(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set event = trigger('actions.about', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/actions.html.twig" as actions %} {% import "about/actions.html.twig" as actions %}
{% block page_title %}{{ 'about.title'|trans({}, 'about') }}{% endblock %} {% block page_title %}{{ 'about.title'|trans({}, 'about') }}{% endblock %}
{% block page_actions %}{{ actions.about('index') }}{% endblock %} {% block page_actions %}{{ actions.about('index') }}{% endblock %}

View File

@@ -21,7 +21,6 @@
<ul> <ul>
<li>AdminLTE: <a href="https://adminlte.io" target="_blank">https://adminlte.io</a></li> <li>AdminLTE: <a href="https://adminlte.io" target="_blank">https://adminlte.io</a></li>
<li>Bootstrap: <a href="https://getbootstrap.com" target="_blank">https://getbootstrap.com</a></li> <li>Bootstrap: <a href="https://getbootstrap.com" target="_blank">https://getbootstrap.com</a></li>
<li>Bootstrap-Select: <a href="https://developer.snapappointments.com/bootstrap-select" target="_blank">https://developer.snapappointments.com/bootstrap-select</a></li>
<li>chart.js: <a href="https://www.chartjs.org" target="_blank">https://www.chartjs.org</a></li> <li>chart.js: <a href="https://www.chartjs.org" target="_blank">https://www.chartjs.org</a></li>
<li>DateRangePicker: <a href="http://www.daterangepicker.com/" target="_blank">http://www.daterangepicker.com/</a></li> <li>DateRangePicker: <a href="http://www.daterangepicker.com/" target="_blank">http://www.daterangepicker.com/</a></li>
<li>Doctrine: <a href="https://www.doctrine-project.org" target="_blank">https://www.doctrine-project.org</a></li> <li>Doctrine: <a href="https://www.doctrine-project.org" target="_blank">https://www.doctrine-project.org</a></li>
@@ -31,9 +30,10 @@
<li>jQuery UI: <a href="https://jqueryui.com" target="_blank">http://jqueryui.com</a></li> <li>jQuery UI: <a href="https://jqueryui.com" target="_blank">http://jqueryui.com</a></li>
<li>MomentJS: <a href="https://momentjs.com" target="_blank">https://momentjs.com</a></li> <li>MomentJS: <a href="https://momentjs.com" target="_blank">https://momentjs.com</a></li>
<li>PHPOffice: <a href="https://github.com/PHPOffice" target="_blank">https://github.com/PHPOffice</a></li> <li>PHPOffice: <a href="https://github.com/PHPOffice" target="_blank">https://github.com/PHPOffice</a></li>
<li>Select2: <a href="https://select2.org/" target="_blank">https://select2.org/</a></li>
<li>SweetAlert 2: <a href="https://sweetalert2.github.io" target="_blank">https://sweetalert2.github.io</a></li> <li>SweetAlert 2: <a href="https://sweetalert2.github.io" target="_blank">https://sweetalert2.github.io</a></li>
<li>Symfony: <a href="https://symfony.com" target="_blank">https://symfony.com</a></li> <li>Symfony: <a href="https://symfony.com" target="_blank">https://symfony.com</a></li>
<li>…</li> <li>… </li>
</ul> </ul>
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}

View File

@@ -1,51 +0,0 @@
{% extends 'about/layout.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block about_box %}
{% endblock %}
{% block about %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}Environment{% endblock %}
{% block box_body %}
<dl>
{% for name, value in dotenv %}
<dt>{{ name }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}PHP{% endblock %}
{% block box_body %}
<dl>
<dt>Version</dt><dd>{{ constant('PHP_VERSION') }}</dd>
<dt>Modules</dt><dd>{{ modules|join(', ') }}</dd>
{% for name, value in settings %}
<dt>{{ name }}</dt>
<dd>
{% if value is empty %}
<i>unknown</i>
{% else %}
{{ value }}
{% endif %}
</dd>
{% endfor %}
</dl>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}Server{% endblock %}
{% block box_body %}
<dl>
{% for name, value in info %}
<dt>{{ name }}</dt>
<dd>{{ value|raw }}</dd>
{% endfor %}
</dl>
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -117,7 +117,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
<li class="dropdown user-menu"> <li class="dropdown user-menu">
<a href="#" class="dropdown-toggle ddt-large" data-toggle="dropdown"> <a href="#" class="dropdown-toggle ddt-large" data-toggle="dropdown">
<i class="{{ 'avatar'|icon }} fa-2x"></i> {{ widgets.user_avatar(app.user, false) }}
</a> </a>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if app.user is not null %} {% if app.user is not null %}
@@ -125,8 +125,8 @@
<li> <li>
<a href="{{ path('user_profile', {'username' : app.user.username}) }}"> <a href="{{ path('user_profile', {'username' : app.user.username}) }}">
<h4 class="control-sidebar-subheading"> <h4 class="control-sidebar-subheading">
<i class="{{ 'profile-stats'|icon }}"></i> <i class="{{ 'avatar'|icon }}"></i>
{{ widgets.username(app.user) }} {{ 'my.profile'|trans }}
</h4> </h4>
</a> </a>
</li> </li>
@@ -136,7 +136,7 @@
<a href="{{ path('user_profile_edit', {'username' : app.user.username}) }}"> <a href="{{ path('user_profile_edit', {'username' : app.user.username}) }}">
<h4 class="control-sidebar-subheading"> <h4 class="control-sidebar-subheading">
<i class="{{ 'profile'|icon }}"></i> <i class="{{ 'profile'|icon }}"></i>
{{ 'profile.title'|trans }} {{ 'action.edit'|trans }}
</h4> </h4>
</a> </a>
</li> </li>

View File

@@ -0,0 +1,11 @@
{% macro calendar(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('create_own_timesheet') %}
{% set actions = actions|merge({'create': {'url': path('timesheet_create'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set event = trigger('actions.calendar', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "macros/actions.html.twig" as actions %} {% import "calendar/actions.html.twig" as actions %}
{% block page_title %}{{ 'calendar.title'|trans }}{% endblock %} {% block page_title %}{{ 'calendar.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.calendar('index') }}{% endblock %} {% block page_actions %}{{ actions.calendar('index') }}{% endblock %}

View File

@@ -0,0 +1,8 @@
{% macro doctor(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set event = trigger('actions.doctor', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -0,0 +1,156 @@
{% extends 'base.html.twig' %}
{% import "doctor/actions.html.twig" as actions %}
{% block page_title %}{{ 'menu.doctor'|trans }}{% endblock %}
{% block page_actions %}{{ actions.doctor('index') }}{% endblock %}
{% block main %}
{% set permissionOpts = {collapsed: true, boxtype: 'success'} %}
{% for name, value in directories %}
{% if value is same as (false) %}
{% set permissionOpts = {boxtype: 'danger'} %}
{% endif %}
{% endfor %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with permissionOpts %}
{% block box_title %}File permissions{% endblock %}
{% block box_body %}
<p>
If you see a warning icon <i class="{{ 'warning'|icon }} text-red"></i> in the list below, you should talk to your administrator.
The path is either not readable or not writable and Kimai might not work as expected.
</p>
<table class="table">
{% for name, value in directories %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>
{% if value is same as (false) %}
<i class="{{ 'warning'|icon }} text-red"></i>
{% else %}
<i class="{{ 'success'|icon }} text-green"></i>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% set extensionOpts = {collapsed: true, boxtype: 'success'} %}
{% for name, value in extensions %}
{% if value is same as (false) %}
{% set extensionOpts = {boxtype: 'danger'} %}
{% endif %}
{% endfor %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with extensionOpts %}
{% block box_title %}Required PHP extensions{% endblock %}
{% block box_body %}
<p>
If you see a warning icon <i class="{{ 'warning'|icon }} text-red"></i> in the list below, you should talk to your administrator.
There is a missing PHP extension and Kimai might not work as expected.
</p>
<table class="table">
{% for name, value in extensions %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>
{% if value is same as (false) %}
<i class="{{ 'warning'|icon }} text-red"></i>
{% else %}
<i class="{{ 'success'|icon }} text-green"></i>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% set logOpts = {collapsed: true} %}
{% if logSize > 52428800 %}
{% set logOpts = {boxtype: 'danger'} %}
{% endif %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with logOpts %}
{% block box_title %}Logfile (max. {{ logLines }} last lines){% endblock %}
{% block box_body %}
{% if logSize > 52428800 %}
<p>Your logfile is larger than 50 MB, consider deleting it (it will be re-created automatically).</p>
{% endif %}
<pre>{% for logLine in logs %}
{{- logLine -}}
{% endfor %}</pre>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {collapsed: true} %}
{% block box_title %}Composer packages{% endblock %}
{% block box_body %}
<table class="table">
{% for name, value in composer %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>{{ value }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {collapsed: true} %}
{% block box_title %}Environment variables{% endblock %}
{% block box_body %}
<table class="table">
{% for name, value in dotenv %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>{{ value }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {collapsed: true} %}
{% block box_title %}PHP{% endblock %}
{% block box_body %}
<table class="table">
<tr>
<th style="width:15%">Version</th>
<td>{{ constant('PHP_VERSION') }}</td>
</tr>
<tr>
<th style="width:15%;max-width:25%">Modules</th>
<td>{{ modules|join(', ') }}</td>
</tr>
{% for name, value in settings %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>
{% if value is empty %}
<i>unknown</i>
{% else %}
{{ value }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {collapsed: true} %}
{% block box_title %}Server{% endblock %}
{% block box_body %}
<table class="table">
{% for name, value in info %}
<tr>
<th style="width:15%">{{ name }}</th>
<td>{{ value|raw }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -77,7 +77,7 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">{{ widgets.label_project(entry.project) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">{{ widgets.label_project(entry.project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description"> <td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{{ entry.description }} {{ entry.description|escape|desc2html }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-nowrap"> <td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-nowrap">
{{ rate|money(currency) }} {{ rate|money(currency) }}

View File

@@ -1,56 +0,0 @@
{% macro calendar(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('create_own_timesheet') %}
{% set actions = actions|merge({'create': {'url': path('timesheet_create'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% set event = trigger('actions.calendar', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro plugins(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'shop': {'url': constant('App\\Constants::HOMEPAGE') ~ '/store/', 'target': '_blank'}} %}
{% set actions = actions|merge({'help': {'url': 'plugins.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.plugins', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro plugin(plugin, view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'home': {'url': plugin.metadata.homepage, 'target': '_blank'}} %}
{% set event = trigger('actions.plugin', {'actions': actions, 'view': view, 'plugin': plugin}) %}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}
{% macro about(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'about': {'url': path('about')}} %}
{% if is_granted('system_information') %}
{% set actions = actions|merge({'debug': path('about_debug')}) %}
{% endif %}
{% set event = trigger('actions.about', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro system_configuration(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set actions = actions|merge({'help': {'url': 'configurations.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.system_configuration', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -29,3 +29,37 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro progressbar_small(max, current, leftValue, leftFormatted) %}
{% set percentReached = 0 %}
{% if max > 0 %}
{% set percentReached = (current / (max / 100)) %}
{% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %}
{% if percentReached > 90 %}
{% set class = "progress-bar-danger" %}
{% elseif percentReached > 70 %}
{% set class = "progress-bar-warning" %}
{% elseif percentReached > 50 %}
{% set class = "progress-bar-success" %}
{% elseif percentReached > 30 %}
{% set class = "progress-bar-primary" %}
{% endif %}
{% if width > 100 %}
{% set width = 100 %}
{% endif %}
<div class="progress-group">
<div class="progress progress-sm">
<div class="progress-bar {{ class }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
</div>
{% if leftValue is not null and leftValue > 0 %}
<small>{{ 'stats.percentUsedLeft'|trans({'%percent%': percentReached|number_format(0), '%left%': leftFormatted}) }}</small>
{% else %}
<small>{{ 'stats.percentUsed'|trans({'%percent%': percentReached|number_format(0)}) }}</small>
{% endif %}
</div>
{% endmacro %}

View File

@@ -44,6 +44,10 @@
{{ macro.label(role, 'danger') }} {{ macro.label(role, 'danger') }}
{% elseif role == 'ROLE_ADMIN' %} {% elseif role == 'ROLE_ADMIN' %}
{{ macro.label(role, 'warning') }} {{ macro.label(role, 'warning') }}
{% elseif role == 'ROLE_TEAMLEAD' %}
{{ macro.label(role, 'success') }}
{% elseif role == 'ROLE_USER' %}
{{ macro.label(role, 'gray') }}
{% else %} {% else %}
{{ macro.label(role, 'primary') }} {{ macro.label(role, 'primary') }}
{% endif %} {% endif %}
@@ -58,6 +62,21 @@
{{ macro.label(user.displayName, 'primary') }} {{ macro.label(user.displayName, 'primary') }}
{% endmacro %} {% endmacro %}
{% macro label_team(team, class) %}
{% import _self as macro %}
{{ macro.label(team.name, class|default('primary')) }}
{% endmacro %}
{% macro user_avatar(user, tooltip) %}
{% set avatar = avatar(user, admin_lte_context.default_avatar) %}
{% set showTooltip = tooltip|default(true) %}
{% if tooltip is not defined or tooltip is same as (false)%}
<img src="{{ avatar }}" class="{{ class|default('img-circle') }}" alt="{{ user.displayName }}" />
{% else %}
<img src="{{ avatar }}" class="{{ class|default('img-circle') }}" data-toggle="tooltip" data-placement="top" alt="{{ user.displayName }}" title="{{ user.displayName }}" />
{% endif %}
{% endmacro %}
{% macro label_activity(activity, url) %} {% macro label_activity(activity, url) %}
{% import _self as macro %} {% import _self as macro %}
{% set isVisible = activity.visible %} {% set isVisible = activity.visible %}

View File

@@ -0,0 +1,22 @@
{% macro plugins(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'shop': {'url': constant('App\\Constants::HOMEPAGE') ~ '/store/', 'target': '_blank'}} %}
{% set actions = actions|merge({'help': {'url': 'plugins.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.plugins', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro plugin(plugin, view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'home': {'url': plugin.metadata.homepage, 'target': '_blank'}} %}
{% set event = trigger('actions.plugin', {'actions': actions, 'view': view, 'plugin': plugin}) %}
{% if view == 'index' %}
{{ widgets.table_actions(event.payload.actions) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}
{% endif %}
{% endmacro %}

View File

@@ -1,6 +1,6 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "macros/actions.html.twig" as actions %} {% import "plugin/actions.html.twig" as actions %}
{% import "macros/datatables.html.twig" as tables %} {% import "macros/datatables.html.twig" as tables %}
{% block page_title %}{{ 'plugins.title'|trans({}, 'plugins') }}{% endblock %} {% block page_title %}{{ 'plugins.title'|trans({}, 'plugins') }}{% endblock %}

View File

@@ -0,0 +1,9 @@
{% macro system_configuration(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% set actions = actions|merge({'help': {'url': 'configurations.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.system_configuration', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/actions.html.twig" as actions %} {% import "system-configuration/actions.html.twig" as actions %}
{% block page_title %}{{ 'title'|trans({}, 'system-configuration') }}{% endblock %} {% block page_title %}{{ 'title'|trans({}, 'system-configuration') }}{% endblock %}
{% block page_subtitle %}{{ 'subtitle'|trans({}, 'system-configuration') }}{% endblock %} {% block page_subtitle %}{{ 'subtitle'|trans({}, 'system-configuration') }}{% endblock %}

View File

@@ -17,7 +17,7 @@
{% set columns = { {% set columns = {
'name': '', 'name': '',
'teamlead': '', 'teamlead': '',
'user': {'class': '', 'orderBy': false}, 'user': {'class': 'hidden-xs', 'orderBy': false},
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
} %} } %}
@@ -26,9 +26,9 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.teamUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.teamUpdate'}) }}
{% for team in teams %} {% for team in teams %}
<tr{% if is_granted('edit', team) %} class="open-edit alternative-link" data-href="{{ path('admin_team_edit', {'id': team.id}) }}"{% endif %}> <tr{% if is_granted('edit', team) %} class="open-edit alternative-link" data-href="{{ path('admin_team_edit', {'id': team.id}) }}"{% endif %}>
<td>{{ team.name }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ team.name }}</td>
<td>{{ widgets.label_user(team.teamlead) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'teamlead') }}">{{ widgets.label_user(team.teamlead) }}</td>
<td> <td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
{% for user in team.users %} {% for user in team.users %}
{{ widgets.label_user(user) }}&nbsp; {{ widgets.label_user(user) }}&nbsp;
{% endfor %} {% endfor %}

View File

@@ -46,7 +46,7 @@
{% if user.id is not empty %} {% if user.id is not empty %}
{% if is_granted('view', user) %} {% if is_granted('view', user) %}
{% set actions = {'profile-stats': {'url': path('user_profile', {'username' : user.username})}} %} {% set actions = actions|merge({'profile-stats': {'url': path('user_profile', {'username' : user.username})}}) %}
{% endif %} {% endif %}
{% if is_granted('edit', user) %} {% if is_granted('edit', user) %}
{% set actions = actions|merge({'edit': path('user_profile_edit', {'username' : user.username})}) %} {% set actions = actions|merge({'edit': path('user_profile_edit', {'username' : user.username})}) %}

View File

@@ -7,8 +7,8 @@
{% set columns = { {% set columns = {
'alias': 'alwaysVisible', 'alias': 'alwaysVisible',
'username': 'hidden-xs', 'username': 'hidden-xs',
'email': 'hidden-xs hidden-sm', 'email': 'hidden-xs hidden-md hidden-sm',
'title': 'hidden-xs', 'title': 'hidden-xs hidden-sm',
} %} } %}
{% for pref in preferences %} {% for pref in preferences %}
{% set columns = columns|merge({ {% set columns = columns|merge({
@@ -16,7 +16,8 @@
}) %} }) %}
{% endfor %} {% endfor %}
{% set columns = columns|merge({ {% set columns = columns|merge({
'roles': {'class': 'hidden-xs', 'orderBy': false}, 'roles': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'team': {'class': 'hidden-xs', 'orderBy': false},
'active': {'class': '', 'orderBy': false}, 'active': {'class': '', 'orderBy': false},
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
}) %} }) %}
@@ -40,7 +41,7 @@
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile_edit', {'username': entry.username}) }}"{% endif %}> <tr{% if is_granted('edit', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile_edit', {'username': entry.username}) }}"{% endif %}>
<td>{{ widgets.username(entry) }}</td> <td>{{ widgets.user_avatar(entry) }} {{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">{{ entry.username }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">{{ entry.username }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
@@ -53,12 +54,22 @@
</td> </td>
{% endfor %} {% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'roles') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'roles') }}">
{% set showUserRole = entry.roles|length == 1 %}
{% for role in entry.roles %} {% for role in entry.roles %}
{% if role != 'ROLE_USER' %} {% if showUserRole or role != 'ROLE_USER' %}
{{ widgets.label_role(role) }} {{ widgets.label_role(role) }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% for team in entry.teams %}
{% if entry.isTeamleadOf(team) %}
{{ widgets.label_team(team, 'success') }}
{% else %}
{{ widgets.label_team(team) }}
{% endif %}
{% endfor %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td>
<td class="actions"> <td class="actions">
{{ actions.user(entry, 'index') }} {{ actions.user(entry, 'index') }}

View File

@@ -48,10 +48,11 @@
<div class="box box-{{ admin_lte_context.widget.type }}"> <div class="box box-{{ admin_lte_context.widget.type }}">
<div class="box-body box-profile"> <div class="box-body box-profile">
{{ macro.avatar(user.avatar, user.username, 'profile-user-img img-responsive img-circle') }} <div class="text-center">
<h3 class="profile-username text-center">{{ widgets.username(user) }}</h3> {{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted text-center">{{ user.title }}</p> <p class="text-muted">{{ user.title }}</p>
</div>
<ul class="list-group list-group-unbordered"> <ul class="list-group list-group-unbordered">

View File

@@ -0,0 +1,54 @@
{% extends 'user/layout.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block main %}
<div class="row">
<div class="col-md-12">
<div class="box box-{{ admin_lte_context.widget.type }} data_table">
<div class="box-body no-padding">
<div class="dataTables_wrapper form-inline dt-bootstrap">
<div class="row">
<div class="col-sm-12">
<table class="table table-hover dataTable" role="grid">
<thead>
<tr>
<th>{{ 'label.team'|trans }}</th>
<th>{{ 'label.teamlead'|trans }}</th>
<th>{{ 'label.user'|trans }}</th>
</tr>
</thead>
<tbody>
{% for team in user.teams %}
<tr>
<td>
{{ team.name }}
</td>
<td>
{{ widgets.user_avatar(team.teamlead) }}
{#
{{ widgets.label(team.teamlead.displayName, 'success', 'label.teamlead'|trans) }}
#}
</td>
<td class="avatars">
{% for user in team.users %}
{{ widgets.user_avatar(user) }}
{#
{{ widgets.label_user(user) }}
#}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,9 +1,10 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="box"> <div class="box">
{# {% if not title is empty %}
<div class="box-header with-border"> <div class="box-header with-border">
<h3 class="box-title">{{ title|trans }}</h3> <h3 class="box-title">{{ title|trans }}</h3>
{#
<div class="box-tools pull-right"> <div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i> <button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i>
</button> </button>
@@ -20,8 +21,9 @@
</div> </div>
<button type="button" class="btn btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button> <button type="button" class="btn btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button>
</div> </div>
</div>
#} #}
</div>
{% endif %}
<div class="box-body"> <div class="box-body">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">

View File

@@ -1,20 +1,22 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% if not title is empty %}
{{ widgets.page_header(title) }}
{% endif %}
{% set width = widgets|length %} {% set width = widgets|length %}
{% set rawWidth = 12 / width %} {% if width > 0 %}
{% set columnWidth = rawWidth|round(0, 'floor') %} {% if not title is empty %}
<div class="row"> {{ widgets.page_header(title) }}
{% for widget in widgets %} {% endif %}
{% set columnSize = columnWidth %}
{% if width == 5 and (loop.first or loop.last) %} {% set rawWidth = 12 / width %}
{% set columnSize = columnWidth + 1 %} {% set columnWidth = rawWidth|round(0, 'floor') %}
{% endif %} <div class="row">
<div class="col-md-{{ columnSize }} col-sm-{{ columnSize * 2 }} col-xs-{{ columnSize * 4 }}"> {% for widget in widgets %}
{{ render_widget(widget) }} {% set columnSize = columnWidth %}
</div> {% if width == 5 and (loop.first or loop.last) %}
{% endfor %} {% set columnSize = columnWidth + 1 %}
</div> {% endif %}
<div class="col-md-{{ columnSize }} col-sm-{{ columnSize * 2 }} col-xs-{{ columnSize * 4 }}">
{{ render_widget(widget) }}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -15,14 +15,6 @@
{{ encore_entry_link_tags('chart') }} {{ encore_entry_link_tags('chart') }}
{{ encore_entry_script_tags('chart') }} {{ encore_entry_script_tags('chart') }}
{% if not title is empty %}
<p class="text-center">
<strong>
{{ title|trans }}
</strong>
</p>
{% endif %}
<div class="chart"> <div class="chart">
<canvas id="{{ chart_id }}" style="height: {{ kimai_context.chart.height }}px;"></canvas> <canvas id="{{ chart_id }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
</div> </div>

View File

@@ -0,0 +1,60 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/progressbar.html.twig" as progress %}
{% set projectStats = data %}
{% set title = options.title|default('label.my_team_projects') %}
{% set widgetId = options.id %}
{% if projectStats|length > 0 %}
<div class="row">
<div class="col-md-12">
<div class="box box-{{ admin_lte_context.widget.type }} WidgetUserTeamProjects" id="{{ widgetId }}">
{% if not title is empty %}
<div class="box-header with-border">
<h3 class="box-title">{{ title|trans }}</h3>
</div>
{% endif %}
<div class="box-body">
<table class="table table-hover dataTable" role="grid">
<thead>
<tr>
<th>{{ 'label.project'|trans }}</th>
<th class="hidden-xs">{{ 'label.team'|trans }}</th>
<th style="width:50%">{{ 'label.progress'|trans }}</th>
</tr>
</thead>
<tbody>
{% for stats in projectStats %}
{% set project = stats.project %}
<tr>
<td>
{{ widgets.label_project(stats.project) }}
<br>
<small>{{ widgets.label_customer(stats.project.customer) }}</small>
</td>
<td class="hidden-xs">
{% for team in project.teams %}
{% if app.user.isInTeam(team) %}
{{ widgets.label_team(team) }}
{% endif %}
{% endfor %}
</td>
<td style="width:50%">
{% if project.timeBudget is not empty and project.timeBudget > 0 %}
{% set budgetLeft = project.timeBudget - stats.recordDuration %}
{{ progress.progressbar_small(project.timeBudget, stats.recordDuration, budgetLeft, budgetLeft|duration) }}
{% elseif project.budget is not empty and project.budget > 0 %}
{% set budgetLeft = project.budget - stats.recordRate %}
{{ progress.progressbar_small(project.budget, stats.recordRate, budgetLeft, budgetLeft|money(project.customer.currency)) }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,58 @@
{% import "macros/widgets.html.twig" as widgets %}
{% set teams = data %}
{% set title = options.title|default('label.my_teams') %}
{% set widgetId = options.id %}
{% if teams|length > 0 %}
<div class="row">
<div class="col-md-12">
<div class="box box-{{ admin_lte_context.widget.type }} WidgetUserTeams" id="{{ widgetId }}">
{% if not title is empty %}
<div class="box-header with-border">
<h3 class="box-title">{{ title|trans }}</h3>
</div>
{% endif %}
<div class="box-body">
<table class="table table-hover dataTable" role="grid">
<thead>
<tr>
<th>{{ 'label.team'|trans }}</th>
<th class="text-center">{{ 'label.teamlead'|trans }}</th>
<th>{{ 'label.user'|trans }}</th>
</tr>
</thead>
<tbody>
{% for team in teams %}
<tr>
<td>
{{ team.name }}
</td>
<td class="text-center">
{{ widgets.user_avatar(team.teamlead) }}
</td>
<td class="avatars">
{% set userTeamCount = team.users|length %}
{% for user in team.users %}
{% set teamHiddenId = widgetId ~ '_' ~ team.id ~ '_hiddenUser' %}
{{ widgets.user_avatar(user) }}
{% if userTeamCount > 5 and loop.index == 5 and not loop.last %}
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - 5)}) }}</a>
<span class="hidden" id="{{ teamHiddenId }}">
{% endif %}
{% if userTeamCount > 5 and loop.index != 5 and loop.last %}
</span>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}

View File

@@ -40,14 +40,14 @@ class TeamControllerTest extends APIControllerBaseTest
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals(1, count($result)); $this->assertEquals(2, count($result));
$this->assertStructure($result[0], false); $this->assertStructure($result[0], false);
} }
public function testGetEntity() public function testGetEntity()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/api/teams/1'); $this->assertAccessIsGranted($client, '/api/teams/2');
$result = json_decode($client->getResponse()->getContent(), true); $result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $this->assertIsArray($result);
@@ -56,7 +56,7 @@ class TeamControllerTest extends APIControllerBaseTest
public function testNotFound() public function testNotFound()
{ {
$this->assertEntityNotFound(User::ROLE_USER, '/api/teams/2'); $this->assertEntityNotFound(User::ROLE_USER, '/api/teams/3');
} }
public function testDeleteActionWithUnknownTeam() public function testDeleteActionWithUnknownTeam()
@@ -67,7 +67,7 @@ class TeamControllerTest extends APIControllerBaseTest
public function testDeleteAction() public function testDeleteAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/api/teams/1'); $this->assertAccessIsGranted($client, '/api/teams/2');
$result = json_decode($client->getResponse()->getContent(), true); $result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $this->assertIsArray($result);

View File

@@ -30,7 +30,7 @@ class UserControllerTest extends APIControllerBaseTest
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals(5, count($result)); $this->assertEquals(7, count($result));
foreach ($result as $user) { foreach ($result as $user) {
$this->assertStructure($user, false); $this->assertStructure($user, false);
} }
@@ -58,7 +58,7 @@ class UserControllerTest extends APIControllerBaseTest
$this->assertIsArray($result); $this->assertIsArray($result);
$this->assertNotEmpty($result); $this->assertNotEmpty($result);
$this->assertEquals(6, count($result)); $this->assertEquals(8, count($result));
foreach ($result as $user) { foreach ($result as $user) {
$this->assertStructure($user, false); $this->assertStructure($user, false);
} }

View File

@@ -16,12 +16,6 @@ use App\Entity\User;
*/ */
class AboutControllerTest extends ControllerBaseTest class AboutControllerTest extends ControllerBaseTest
{ {
public function testDebugIsSecure()
{
$this->assertUrlIsSecured('/about/debug');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/about/debug');
}
public function testIndexAction() public function testIndexAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -34,18 +28,4 @@ class AboutControllerTest extends ControllerBaseTest
$this->assertEquals(1, count($result)); $this->assertEquals(1, count($result));
$this->assertContains('MIT License', $result->text()); $this->assertContains('MIT License', $result->text());
} }
public function testDebugAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/about/debug');
$content = $client->getResponse()->getContent();
$this->assertContains('<h3 class="box-title">Environment</h3>', $content);
$this->assertContains('<h3 class="box-title">PHP</h3>', $content);
$this->assertContains('<h3 class="box-title">Server</h3>', $content);
$this->assertContains('', $content);
$this->assertContains('PHP', $content);
}
} }

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use App\Entity\User; use App\Entity\User;
/** /**
@@ -29,6 +30,19 @@ class DashboardControllerTest extends ControllerBaseTest
$this->assertMainContentClass($client, 'dashboard'); $this->assertMainContentClass($client, 'dashboard');
} }
public function testIndexActionForUserWithTeams()
{
$client = self::createClient([], [
'PHP_AUTH_USER' => 'test_user_1',
'PHP_AUTH_PW' => UserFixtures::DEFAULT_PASSWORD,
]);
$this->request($client, '/dashboard/');
$this->assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(1, $client->getCrawler()->filter('section.content .WidgetUserTeams')->count());
// team 1 has no project assignment right now
self::assertEquals(0, $client->getCrawler()->filter('section.content .WidgetUserTeamProjects')->count());
}
public function testIndexActionForAdmin() public function testIndexActionForAdmin()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Controller;
use App\Entity\User;
/**
* @group integration
*/
class DoctorControllerTest extends ControllerBaseTest
{
public function testDoctorIsSecure()
{
$this->assertUrlIsSecured('/doctor');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/doctor');
}
public function testIndexAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/doctor');
$result = $client->getCrawler()->filter('.content .box-header');
$this->assertEquals(7, count($result));
}
}

View File

@@ -35,7 +35,7 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client); $this->assertHasNoEntriesWithFilter($client);
$this->assertHasProfileBox($client, UserFixtures::USERNAME_USER); $this->assertHasProfileBox($client, 'John Doe');
$this->assertHasAboutMeBox($client, UserFixtures::USERNAME_USER); $this->assertHasAboutMeBox($client, UserFixtures::USERNAME_USER);
} }
@@ -68,7 +68,7 @@ class ProfileControllerTest extends ControllerBaseTest
$this->assertContains('var userProfileChart' . $year . ' = new Chart(', $content); $this->assertContains('var userProfileChart' . $year . ' = new Chart(', $content);
} }
$this->assertHasProfileBox($client, UserFixtures::USERNAME_USER); $this->assertHasProfileBox($client, 'John Doe');
$this->assertHasAboutMeBox($client, UserFixtures::USERNAME_USER); $this->assertHasAboutMeBox($client, UserFixtures::USERNAME_USER);
} }
@@ -76,7 +76,7 @@ class ProfileControllerTest extends ControllerBaseTest
{ {
$profileBox = $client->getCrawler()->filter('div.box-body.box-profile'); $profileBox = $client->getCrawler()->filter('div.box-body.box-profile');
$this->assertEquals(1, $profileBox->count()); $this->assertEquals(1, $profileBox->count());
$profileAvatar = $profileBox->filter('img.profile-user-img'); $profileAvatar = $profileBox->filter('img.img-circle');
$this->assertEquals(1, $profileAvatar->count()); $this->assertEquals(1, $profileAvatar->count());
$alt = $profileAvatar->attr('alt'); $alt = $profileAvatar->attr('alt');

View File

@@ -43,7 +43,7 @@ class TeamControllerTest extends ControllerBaseTest
'help' => 'https://www.kimai.org/documentation/teams.html' 'help' => 'https://www.kimai.org/documentation/teams.html'
]); ]);
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_admin_teams', 5); $this->assertDataTableRowCount($client, 'datatable_admin_teams', 6);
} }
public function testIndexActionWithSearchTermQuery() public function testIndexActionWithSearchTermQuery()
@@ -85,7 +85,7 @@ class TeamControllerTest extends ControllerBaseTest
'name' => 'Test Team', 'name' => 'Test Team',
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit')); $this->assertIsRedirect($client, $this->createUrl('/admin/teams/2/edit'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
$this->assertHasCustomerAndProjectPermissionBoxes($client); $this->assertHasCustomerAndProjectPermissionBoxes($client);

View File

@@ -29,7 +29,7 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/'); $this->assertAccessIsGranted($client, '/admin/user/');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin', 5); $this->assertDataTableRowCount($client, 'datatable_user_admin', 7);
} }
public function testIndexActionWithSearchTermQuery() public function testIndexActionWithSearchTermQuery()
@@ -232,6 +232,6 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions'); $this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 81); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83);
} }
} }

View File

@@ -28,26 +28,36 @@ class TimesheetTest extends TestCase
public function testDefaultValues() public function testDefaultValues()
{ {
$sut = new Timesheet(); $sut = new Timesheet();
$this->assertNull($sut->getId()); self::assertNull($sut->getId());
$this->assertNull($sut->getBegin()); self::assertNull($sut->getBegin());
$this->assertNull($sut->getEnd()); self::assertNull($sut->getEnd());
$this->assertSame(0, $sut->getDuration()); self::assertSame(0, $sut->getDuration());
$this->assertNull($sut->getUser()); self::assertNull($sut->getUser());
$this->assertNull($sut->getActivity()); self::assertNull($sut->getActivity());
$this->assertNull($sut->getProject()); self::assertNull($sut->getProject());
$this->assertNull($sut->getDescription()); self::assertNull($sut->getDescription());
$this->assertSame(0.00, $sut->getRate()); self::assertSame(0.00, $sut->getRate());
$this->assertNull($sut->getFixedRate()); self::assertNull($sut->getFixedRate());
$this->assertNull($sut->getHourlyRate()); self::assertNull($sut->getHourlyRate());
$this->assertEquals(new ArrayCollection(), $sut->getTags()); self::assertEquals(new ArrayCollection(), $sut->getTags());
$this->assertEquals([], $sut->getTagsAsArray()); self::assertEquals([], $sut->getTagsAsArray());
$this->assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47)); self::assertInstanceOf(Timesheet::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate()); self::assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99)); self::assertInstanceOf(Timesheet::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate()); self::assertEquals(99, $sut->getHourlyRate());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields()); self::assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count()); self::assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo')); self::assertNull($sut->getMetaField('foo'));
}
public function testDurationCanBeNull()
{
$sut = new Timesheet();
self::assertEquals(0, $sut->getDuration());
$sut->setDuration(null);
self::assertNull($sut->getDuration());
$sut->setDuration(-1);
self::assertEquals(-1, $sut->getDuration());
} }
protected function getEntity() protected function getEntity()
@@ -84,11 +94,11 @@ class TimesheetTest extends TestCase
$sut->addTag($tag); $sut->addTag($tag);
$sut->addTag($tag1); $sut->addTag($tag1);
$this->assertEquals([0 => 'bar', 1 => 'foo'], $sut->getTagsAsArray()); self::assertEquals([0 => 'bar', 1 => 'foo'], $sut->getTagsAsArray());
$this->assertEquals(new ArrayCollection([$tag, $tag1]), $sut->getTags()); self::assertEquals(new ArrayCollection([$tag, $tag1]), $sut->getTags());
$sut->removeTag($tag); $sut->removeTag($tag);
$this->assertEquals([1 => 'foo'], $sut->getTagsAsArray()); self::assertEquals([1 => 'foo'], $sut->getTagsAsArray());
$sut->removeTag($tag1); $sut->removeTag($tag1);
$this->assertEmpty($sut->getTags()); $this->assertEmpty($sut->getTags());
@@ -99,7 +109,7 @@ class TimesheetTest extends TestCase
$sut = new Timesheet(); $sut = new Timesheet();
$meta = new TimesheetMeta(); $meta = new TimesheetMeta();
$meta->setName('foo')->setValue('bar')->setType('test'); $meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Timesheet::class, $sut->setMetaField($meta)); self::assertInstanceOf(Timesheet::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count()); self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo'); $result = $sut->getMetaField('foo');
self::assertSame($result, $meta); self::assertSame($result, $meta);
@@ -107,7 +117,7 @@ class TimesheetTest extends TestCase
$meta2 = new TimesheetMeta(); $meta2 = new TimesheetMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2'); $meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Timesheet::class, $sut->setMetaField($meta2)); self::assertInstanceOf(Timesheet::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count()); self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields()); self::assertCount(0, $sut->getVisibleMetaFields());

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Model; namespace App\Tests\Model;
use App\Entity\Project;
use App\Model\ProjectStatistic; use App\Model\ProjectStatistic;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -19,21 +20,23 @@ class ProjectStatisticTest extends TestCase
{ {
public function testDefaultValues() public function testDefaultValues()
{ {
$sut = new ProjectStatistic(); $sut = new ProjectStatistic(new Project());
$this->assertEquals(0, $sut->getActivityAmount()); self::assertEquals(0, $sut->getActivityAmount());
$this->assertEquals(0, $sut->getRecordAmount()); self::assertEquals(0, $sut->getRecordAmount());
$this->assertEquals(0, $sut->getRecordDuration()); self::assertEquals(0, $sut->getRecordDuration());
} }
public function testSetter() public function testSetter()
{ {
$sut = new ProjectStatistic(); $project = new Project();
$sut = new ProjectStatistic($project);
$sut->setRecordAmount(7654.298); $sut->setRecordAmount(7654.298);
$sut->setRecordDuration(826.10); $sut->setRecordDuration(826.10);
$sut->setActivityAmount(13); $sut->setActivityAmount(13);
$this->assertEquals(13, $sut->getActivityAmount()); self::assertEquals(13, $sut->getActivityAmount());
$this->assertEquals(7654, $sut->getRecordAmount()); self::assertEquals(7654, $sut->getRecordAmount());
$this->assertEquals(826, $sut->getRecordDuration()); self::assertEquals(826, $sut->getRecordDuration());
self::assertSame($project, $sut->getProject());
} }
} }

View File

@@ -30,6 +30,16 @@ class BaseQueryTest extends TestCase
$this->assertResetByFormError(new BaseQuery()); $this->assertResetByFormError(new BaseQuery());
} }
/**
* @expectedDeprecation BaseQuery::getResultType() is deprecated and will be removed with 1.6
* @group legacy
*/
public function testDeprecations()
{
$sut = new BaseQuery();
$sut->getResultType();
}
protected function assertResetByFormError(BaseQuery $sut, $orderBy = 'id', $order = 'ASC') protected function assertResetByFormError(BaseQuery $sut, $orderBy = 'id', $order = 'ASC')
{ {
$sut->setOrder('ASK'); $sut->setOrder('ASK');
@@ -49,7 +59,6 @@ class BaseQueryTest extends TestCase
protected function assertBaseQuery(BaseQuery $sut, $orderBy = 'id') protected function assertBaseQuery(BaseQuery $sut, $orderBy = 'id')
{ {
$this->assertResultType($sut);
$this->assertPage($sut); $this->assertPage($sut);
$this->assertPageSize($sut); $this->assertPageSize($sut);
$this->assertOrderBy($sut, $orderBy); $this->assertOrderBy($sut, $orderBy);
@@ -85,24 +94,6 @@ class BaseQueryTest extends TestCase
$sut->resetByFormError($formErrors); $sut->resetByFormError($formErrors);
} }
protected function assertResultType(BaseQuery $sut)
{
self::assertEquals(BaseQuery::RESULT_TYPE_PAGER, $sut->getResultType());
$sut->setResultType(BaseQuery::RESULT_TYPE_QUERYBUILDER);
self::assertEquals(BaseQuery::RESULT_TYPE_QUERYBUILDER, $sut->getResultType());
$sut->setResultType(BaseQuery::RESULT_TYPE_OBJECTS);
self::assertEquals(BaseQuery::RESULT_TYPE_OBJECTS, $sut->getResultType());
try {
$sut->setResultType('foo-bar');
} catch (\Exception $exception) {
$this->assertInstanceOf(\InvalidArgumentException::class, $exception);
self::assertEquals('Unsupported query result type', $exception->getMessage());
}
}
protected function assertTeams(BaseQuery $sut) protected function assertTeams(BaseQuery $sut)
{ {
self::assertEmpty($sut->getTeams()); self::assertEmpty($sut->getTeams());

View File

@@ -24,7 +24,6 @@ class ExportQueryTest extends BaseQueryTest
{ {
$sut = new ExportQuery(); $sut = new ExportQuery();
$this->assertResultType($sut);
$this->assertPage($sut); $this->assertPage($sut);
$this->assertPageSize($sut); $this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin'); $this->assertOrderBy($sut, 'begin');

View File

@@ -24,7 +24,6 @@ class InvoiceQueryTest extends BaseQueryTest
{ {
$sut = new InvoiceQuery(); $sut = new InvoiceQuery();
$this->assertResultType($sut);
$this->assertPage($sut); $this->assertPage($sut);
$this->assertPageSize($sut); $this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin'); $this->assertOrderBy($sut, 'begin');

View File

@@ -25,7 +25,6 @@ class TimesheetQueryTest extends BaseQueryTest
{ {
$sut = new TimesheetQuery(); $sut = new TimesheetQuery();
$this->assertResultType($sut);
$this->assertPage($sut); $this->assertPage($sut);
$this->assertPageSize($sut); $this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin'); $this->assertOrderBy($sut, 'begin');

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