added project detail report (#2651)
- avatars via css only - random colors for entities - improves print view - added colors for teams - added color to tag
This commit is contained in:
@@ -29,3 +29,4 @@
|
||||
@import 'forms';
|
||||
@import 'modal';
|
||||
@import 'progressbar';
|
||||
@import 'avatar';
|
||||
|
||||
78
assets/sass/avatar.scss
Normal file
78
assets/sass/avatar.scss
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
$avatar-base-size: 30;
|
||||
|
||||
.avatar {
|
||||
text-align: center;
|
||||
background-color: #d2d6de;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
height: ($avatar-base-size)+px;
|
||||
width: ($avatar-base-size)+px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.initials {
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
font-weight: bold;
|
||||
font-size: ($avatar-base-size / 2 - 2)+px;
|
||||
top: ($avatar-base-size / 4 - 1)+px;
|
||||
}
|
||||
|
||||
.avatar-xs {
|
||||
width: ($avatar-base-size * 0.75)+px;
|
||||
height: ($avatar-base-size * 0.75)+px;
|
||||
.initials {
|
||||
font-size: ($avatar-base-size * .75 / 2 - 2)+px;
|
||||
top: ($avatar-base-size * .75 / 4 - 1)+px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: ($avatar-base-size * 1.25)+px;
|
||||
height: ($avatar-base-size * 1.25)+px;
|
||||
.initials {
|
||||
font-size: ($avatar-base-size * 1.25 / 2 - 2)+px;
|
||||
top: ($avatar-base-size * 1.25 / 4 - 1)+px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
width: ($avatar-base-size * 1.5)+px;
|
||||
height: ($avatar-base-size * 1.5)+px;
|
||||
.initials {
|
||||
font-size: ($avatar-base-size * 1.5 / 2 - 2)+px;
|
||||
top: ($avatar-base-size * 1.5 / 4 - 1)+px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-lg {
|
||||
width: ($avatar-base-size * 2)+px;
|
||||
height: ($avatar-base-size * 2)+px;
|
||||
.initials {
|
||||
font-size: ($avatar-base-size * 2 / 2 - 2)+px;
|
||||
top: ($avatar-base-size * 2 / 4 - 1)+px;
|
||||
}
|
||||
}
|
||||
|
||||
.widget-user-image {
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
.avatar {
|
||||
width: ($avatar-base-size * 2.75)+px;
|
||||
height: ($avatar-base-size * 2.75)+px;
|
||||
.initials {
|
||||
font-size: ($avatar-base-size * 2.75 / 2 - 2)+px;
|
||||
top: ($avatar-base-size * 2.75 / 4 - 1)+px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Detail tables like my teams (Dashboard) or */
|
||||
table.dataTable.table > tbody > tr > td {
|
||||
&.avatars {
|
||||
.avatar {
|
||||
margin: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,23 +36,6 @@ td {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
/* Profile */
|
||||
.avatar-xs {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
.avatar-sm {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
}
|
||||
.avatar-md {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
.avatar-lg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.box-profile {
|
||||
img.img-circle {
|
||||
width: 100px;
|
||||
@@ -64,6 +47,7 @@ td {
|
||||
|
||||
.label-gray {
|
||||
background-color: $gray-lte;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Delete link in action dropdowns */
|
||||
@@ -84,4 +68,14 @@ td.w-min {
|
||||
table.dataTable thead > tr > th.hw-min {
|
||||
width: 1%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-weight: normal;
|
||||
color: #000;
|
||||
background-color: $kimai-default;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -5,9 +5,27 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
|
||||
/* custom extension to make the column wide enough to show the sortable icons */
|
||||
table.dataTable th.sortable {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
table.dataTable.table > tbody > tr > td {
|
||||
vertical-align: middle;
|
||||
.img-circle {
|
||||
max-width: 30px;
|
||||
margin-right: 0;
|
||||
&.teamlead {
|
||||
box-shadow: 0 0 5px 3px $gray-lte;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span.label-color,
|
||||
span.label-meta,
|
||||
span.label-tag,
|
||||
span.label-user,
|
||||
span.label-activity,
|
||||
span.label-project,
|
||||
span.label-customer {
|
||||
@@ -37,6 +55,9 @@ table.dataTable {
|
||||
&.overlapping {
|
||||
border-top: 2px dotted red;
|
||||
}
|
||||
&.exported {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
td {
|
||||
/*
|
||||
@@ -48,7 +69,6 @@ table.dataTable {
|
||||
}
|
||||
.label {
|
||||
font-size: 85%;
|
||||
font-weight: 500;
|
||||
padding: .3em .6em .3em;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -69,3 +89,13 @@ table.dataTable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table.table-hover {
|
||||
tr {
|
||||
&.exported {
|
||||
&:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -266,22 +266,3 @@ div.dataTables_scrollHead table.table-bordered {
|
||||
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
|
||||
background-color: #017ebc;
|
||||
}
|
||||
|
||||
/* custom extension to make the column wide enough to show the sortable icons */
|
||||
table.dataTable th.sortable {
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
table.dataTable.table > tbody > tr > td {
|
||||
&.avatars {
|
||||
line-height: 40px;
|
||||
}
|
||||
vertical-align: middle;
|
||||
.img-circle {
|
||||
max-width: 35px;
|
||||
margin-right: 10px;
|
||||
&.teamlead {
|
||||
box-shadow: 0 0 5px 3px $gray-lte;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
max-height: 26px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.avatar {
|
||||
max-height: 26px;
|
||||
max-width: 26px;
|
||||
margin-top: -2px;
|
||||
.initials {
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
li>a.ddt-large {
|
||||
padding: 15px 12px 11px 10px;
|
||||
font-size: 10px;
|
||||
|
||||
@@ -6,6 +6,21 @@
|
||||
*/
|
||||
|
||||
@media print{
|
||||
.sf-toolbar, .control-sidebar {display: none !important;}
|
||||
.col-print-12{width:100%;}
|
||||
.sf-toolbar, .control-sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
.col-print-12{
|
||||
width:100%;
|
||||
}
|
||||
.sidebar-mini.sidebar-collapse .content-wrapper, .sidebar-mini.sidebar-collapse .main-footer, .sidebar-mini.sidebar-collapse .right-side {
|
||||
margin: 0!important;
|
||||
}
|
||||
section.content {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
span i.dot {
|
||||
display: none;
|
||||
margin-right: 0!important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ $blue: #0073b7;
|
||||
//$maroon: #D81B60;
|
||||
//$black: #111;
|
||||
$gray-lte: #d2d6de;
|
||||
$kimai-default: $gray-lte;
|
||||
|
||||
// LAYOUT
|
||||
// --------------------------------------------------------
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"jms/metadata": "^2.0",
|
||||
"jms/serializer-bundle": "^3.9",
|
||||
"kevinpapst/adminlte-bundle": "dev-master",
|
||||
"laravolt/avatar": "^4.0",
|
||||
"league/csv": "^9.4",
|
||||
"league/html-to-markdown": "^5.0",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
|
||||
705
composer.lock
generated
705
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2e698a91ffa36afbce0e7ddd765edc00",
|
||||
"content-hash": "d4fcb30ed5f1db78d4f32f09388ea0b3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "beberlei/doctrineextensions",
|
||||
@@ -2039,81 +2039,6 @@
|
||||
},
|
||||
"time": "2021-04-23T09:16:01+00:00"
|
||||
},
|
||||
{
|
||||
"name": "guzzlehttp/psr7",
|
||||
"version": "1.8.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/guzzle/psr7.git",
|
||||
"reference": "dc960a912984efb74d0a90222870c72c87f10c91"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/guzzle/psr7/zipball/dc960a912984efb74d0a90222870c72c87f10c91",
|
||||
"reference": "dc960a912984efb74d0a90222870c72c87f10c91",
|
||||
"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.14 || ^7.5.20 || ^8.5.8 || ^9.3.10"
|
||||
},
|
||||
"suggest": {
|
||||
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.7-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"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/guzzle/psr7/issues",
|
||||
"source": "https://github.com/guzzle/psr7/tree/1.8.2"
|
||||
},
|
||||
"time": "2021-04-26T09:17:50+00:00"
|
||||
},
|
||||
{
|
||||
"name": "handcraftedinthealps/rest-routing-bundle",
|
||||
"version": "1.0.2",
|
||||
@@ -2191,353 +2116,6 @@
|
||||
},
|
||||
"time": "2021-01-08T09:04:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/cache",
|
||||
"version": "v8.45.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/cache.git",
|
||||
"reference": "499d15db9b58b5701aa85a4790ae1404d79009aa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/cache/zipball/499d15db9b58b5701aa85a4790ae1404d79009aa",
|
||||
"reference": "499d15db9b58b5701aa85a4790ae1404d79009aa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/collections": "^8.0",
|
||||
"illuminate/contracts": "^8.0",
|
||||
"illuminate/macroable": "^8.0",
|
||||
"illuminate/support": "^8.0",
|
||||
"php": "^7.3|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-memcached": "Required to use the memcache cache driver.",
|
||||
"illuminate/database": "Required to use the database cache driver (^8.0).",
|
||||
"illuminate/filesystem": "Required to use the file cache driver (^8.0).",
|
||||
"illuminate/redis": "Required to use the redis cache driver (^8.0).",
|
||||
"symfony/cache": "Required to PSR-6 cache bridge (^5.1.4)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-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",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2021-05-18T12:49:19+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/collections",
|
||||
"version": "v8.45.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/collections.git",
|
||||
"reference": "deccb956d38710f3f8baf36dd876c3fa1585ec22"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/collections/zipball/deccb956d38710f3f8baf36dd876c3fa1585ec22",
|
||||
"reference": "deccb956d38710f3f8baf36dd876c3fa1585ec22",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/contracts": "^8.0",
|
||||
"illuminate/macroable": "^8.0",
|
||||
"php": "^7.3|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/var-dumper": "Required to use the dump method (^5.1.4)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-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 Collections package.",
|
||||
"homepage": "https://laravel.com",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2021-04-22T21:08:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/contracts",
|
||||
"version": "v8.45.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/contracts.git",
|
||||
"reference": "199fcedc161ba4a0b83feaddc4629f395dbf1641"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/contracts/zipball/199fcedc161ba4a0b83feaddc4629f395dbf1641",
|
||||
"reference": "199fcedc161ba4a0b83feaddc4629f395dbf1641",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.3|^8.0",
|
||||
"psr/container": "^1.0",
|
||||
"psr/simple-cache": "^1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-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",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2021-06-01T14:53:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/macroable",
|
||||
"version": "v8.45.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/macroable.git",
|
||||
"reference": "300aa13c086f25116b5f3cde3ca54ff5c822fb05"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/macroable/zipball/300aa13c086f25116b5f3cde3ca54ff5c822fb05",
|
||||
"reference": "300aa13c086f25116b5f3cde3ca54ff5c822fb05",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.3|^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Illuminate\\Support\\": ""
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "The Illuminate Macroable package.",
|
||||
"homepage": "https://laravel.com",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2020-10-27T15:20:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "illuminate/support",
|
||||
"version": "v8.45.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/illuminate/support.git",
|
||||
"reference": "3832c4a0ba0f0734de8689500164122f5f51c8ab"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/illuminate/support/zipball/3832c4a0ba0f0734de8689500164122f5f51c8ab",
|
||||
"reference": "3832c4a0ba0f0734de8689500164122f5f51c8ab",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/inflector": "^1.4|^2.0",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"illuminate/collections": "^8.0",
|
||||
"illuminate/contracts": "^8.0",
|
||||
"illuminate/macroable": "^8.0",
|
||||
"nesbot/carbon": "^2.31",
|
||||
"php": "^7.3|^8.0",
|
||||
"voku/portable-ascii": "^1.4.8"
|
||||
},
|
||||
"conflict": {
|
||||
"tightenco/collect": "<5.5.33"
|
||||
},
|
||||
"suggest": {
|
||||
"illuminate/filesystem": "Required to use the composer class (^8.0).",
|
||||
"league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^1.3).",
|
||||
"ramsey/uuid": "Required to use Str::uuid() (^4.0).",
|
||||
"symfony/process": "Required to use the composer class (^5.1.4).",
|
||||
"symfony/var-dumper": "Required to use the dd function (^5.1.4).",
|
||||
"vlucas/phpdotenv": "Required to use the Env class and env helper (^5.2)."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "8.x-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",
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2021-06-01T19:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "2.5.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
|
||||
"reference": "abbf18d5ab8367f96b3205ca3c89fb2fa598c69e",
|
||||
"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"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/master"
|
||||
},
|
||||
"time": "2019-11-02T09:15:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "jms/metadata",
|
||||
"version": "2.5.0",
|
||||
@@ -3123,76 +2701,6 @@
|
||||
],
|
||||
"time": "2021-02-25T21:54:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravolt/avatar",
|
||||
"version": "4.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravolt/avatar.git",
|
||||
"reference": "3ab66e32c1f7c84e7095adfa1de3cb20964e6a1e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravolt/avatar/zipball/3ab66e32c1f7c84e7095adfa1de3cb20964e6a1e",
|
||||
"reference": "3ab66e32c1f7c84e7095adfa1de3cb20964e6a1e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/cache": "6.* | 7.*| 8.*",
|
||||
"illuminate/support": "6.* | 7.* | 8.*",
|
||||
"intervention/image": "^2.5",
|
||||
"php": ">=7.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1.3",
|
||||
"php-coveralls/php-coveralls": "^2.1",
|
||||
"phpunit/phpunit": "~9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.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": "https://laravolt.dev",
|
||||
"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"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravolt/avatar/issues",
|
||||
"source": "https://github.com/laravolt/avatar/tree/4.1.3"
|
||||
},
|
||||
"time": "2021-03-25T01:43:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/csv",
|
||||
"version": "9.7.1",
|
||||
@@ -4093,99 +3601,6 @@
|
||||
},
|
||||
"time": "2021-04-20T08:27:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nesbot/carbon",
|
||||
"version": "2.48.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/briannesbitt/Carbon.git",
|
||||
"reference": "8d1f50f1436fb4b05e7127360483dd9c6e73da16"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/8d1f50f1436fb4b05e7127360483dd9c6e73da16",
|
||||
"reference": "8d1f50f1436fb4b05e7127360483dd9c6e73da16",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"php": "^7.1.8 || ^8.0",
|
||||
"symfony/polyfill-mbstring": "^1.0",
|
||||
"symfony/translation": "^3.4 || ^4.0 || ^5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/orm": "^2.7",
|
||||
"friendsofphp/php-cs-fixer": "^2.14 || ^3.0",
|
||||
"kylekatarnls/multi-tester": "^2.0",
|
||||
"phpmd/phpmd": "^2.9",
|
||||
"phpstan/extension-installer": "^1.0",
|
||||
"phpstan/phpstan": "^0.12.54",
|
||||
"phpunit/phpunit": "^7.5.20 || ^8.5.14",
|
||||
"squizlabs/php_codesniffer": "^3.4"
|
||||
},
|
||||
"bin": [
|
||||
"bin/carbon"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev",
|
||||
"dev-3.x": "3.x-dev"
|
||||
},
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Carbon\\Laravel\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": "An API extension for DateTime that supports 281 different languages.",
|
||||
"homepage": "http://carbon.nesbot.com",
|
||||
"keywords": [
|
||||
"date",
|
||||
"datetime",
|
||||
"time"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/briannesbitt/Carbon/issues",
|
||||
"source": "https://github.com/briannesbitt/Carbon"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://opencollective.com/Carbon",
|
||||
"type": "open_collective"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/nesbot/carbon",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2021-05-26T22:08:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "onelogin/php-saml",
|
||||
"version": "3.6.1",
|
||||
@@ -5166,50 +4581,6 @@
|
||||
},
|
||||
"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.",
|
||||
"support": {
|
||||
"issues": "https://github.com/ralouphie/getallheaders/issues",
|
||||
"source": "https://github.com/ralouphie/getallheaders/tree/develop"
|
||||
},
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "robrichards/xmlseclibs",
|
||||
"version": "3.1.1",
|
||||
@@ -11299,80 +10670,6 @@
|
||||
],
|
||||
"time": "2021-05-16T12:14:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "voku/portable-ascii",
|
||||
"version": "1.5.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/voku/portable-ascii.git",
|
||||
"reference": "80953678b19901e5165c56752d087fc11526017c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/voku/portable-ascii/zipball/80953678b19901e5165c56752d087fc11526017c",
|
||||
"reference": "80953678b19901e5165c56752d087fc11526017c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-intl": "Use Intl for transliterator_transliterate() support"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"voku\\": "src/voku/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lars Moelleken",
|
||||
"homepage": "http://www.moelleken.org/"
|
||||
}
|
||||
],
|
||||
"description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
|
||||
"homepage": "https://github.com/voku/portable-ascii",
|
||||
"keywords": [
|
||||
"ascii",
|
||||
"clean",
|
||||
"php"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/voku/portable-ascii/issues",
|
||||
"source": "https://github.com/voku/portable-ascii/tree/1.5.6"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.paypal.me/moelleken",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/voku",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://opencollective.com/portable-ascii",
|
||||
"type": "open_collective"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/voku",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2020-11-12T00:07:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "webmozart/assert",
|
||||
"version": "1.10.0",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<env name="MAILER_FROM" value="kimai@example.com"/>
|
||||
|
||||
<!--
|
||||
REINSTALL THE TEST DATABASE, AFTER CHANGING STRUCTURE?
|
||||
REINSTALL THE TEST DATABASE, eg. AFTER CHANGING STRUCTURE!
|
||||
|
||||
Simple:
|
||||
- change to "true"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
"build/app.1cecb23c.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.cf7b7f85.css"
|
||||
"build/app.079fbef2.css"
|
||||
]
|
||||
},
|
||||
"invoice": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"build/app.css": "build/app.cf7b7f85.css",
|
||||
"build/app.css": "build/app.079fbef2.css",
|
||||
"build/app.js": "build/app.1cecb23c.js",
|
||||
"build/invoice.css": "build/invoice.ff32661a.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
|
||||
@@ -362,6 +362,16 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return (bool) $this->find('theme.colors_limited');
|
||||
}
|
||||
|
||||
public function isThemeRandomColors(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.random_colors');
|
||||
}
|
||||
|
||||
public function isThemeAllowAvatarUrls(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.avatar_url');
|
||||
}
|
||||
|
||||
public function getThemeAutocompleteCharacters(): int
|
||||
{
|
||||
return (int) $this->find('theme.autocomplete_chars');
|
||||
@@ -384,11 +394,6 @@ class SystemConfiguration implements SystemBundleConfiguration
|
||||
return $this->find('theme.branding.title');
|
||||
}
|
||||
|
||||
public function getBrandingCompany(): ?string
|
||||
{
|
||||
return $this->find('theme.branding.company');
|
||||
}
|
||||
|
||||
public function isAllowTagCreation(): bool
|
||||
{
|
||||
return (bool) $this->find('theme.tags_create');
|
||||
|
||||
@@ -43,7 +43,6 @@ class DoctorController extends AbstractController
|
||||
'var/data/',
|
||||
'var/log/',
|
||||
'var/sessions/',
|
||||
'public/avatars/',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,7 +62,6 @@ final class ProfileController extends AbstractController
|
||||
'user' => $profile,
|
||||
'stats' => $userStats,
|
||||
'years' => $monthlyStats,
|
||||
'stat_date_format' => $localeSettings->getDatePickerFormat(),
|
||||
];
|
||||
|
||||
return $this->render('user/stats.html.twig', $viewVars);
|
||||
@@ -111,7 +110,7 @@ final class ProfileController extends AbstractController
|
||||
return $this->redirectToRoute('user_profile_password', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->render('user/profile.html.twig', [
|
||||
return $this->render('user/form.html.twig', [
|
||||
'tab' => 'password',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
@@ -169,7 +168,7 @@ final class ProfileController extends AbstractController
|
||||
return $this->redirectToRoute('user_profile_roles', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->render('user/profile.html.twig', [
|
||||
return $this->render('user/form.html.twig', [
|
||||
'tab' => 'roles',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
@@ -195,7 +194,7 @@ final class ProfileController extends AbstractController
|
||||
return $this->redirectToRoute('user_profile_teams', ['username' => $profile->getUsername()]);
|
||||
}
|
||||
|
||||
return $this->render('user/profile.html.twig', [
|
||||
return $this->render('user/form.html.twig', [
|
||||
'tab' => 'teams',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
@@ -276,7 +275,7 @@ final class ProfileController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('user/form.html.twig', [
|
||||
return $this->render('user/preferences.html.twig', [
|
||||
'tab' => 'preferences',
|
||||
'user' => $profile,
|
||||
'form' => $form->createView(),
|
||||
|
||||
53
src/Controller/Reporting/ProjectDetailsController.php
Normal file
53
src/Controller/Reporting/ProjectDetailsController.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Entity\Project;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsForm;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
|
||||
use App\Utils\LocaleSettings;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
final class ProjectDetailsController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* @Route(path="/reporting/project_details", name="report_project_details", methods={"GET"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('details_project')")
|
||||
*/
|
||||
public function __invoke(Request $request, ProjectStatisticService $service, LocaleSettings $localeSettings)
|
||||
{
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectDetailsQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createForm(ProjectDetailsForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$projectView = null;
|
||||
$projectDetails = null;
|
||||
|
||||
if ($query->getProject() !== null && $this->isGranted('details', $query->getProject())) {
|
||||
$projectViews = $service->getProjectView($user, [$query->getProject()], $query->getToday());
|
||||
$projectView = $projectViews[0];
|
||||
$projectDetails = $service->getProjectsDetails($query);
|
||||
}
|
||||
|
||||
return $this->render('reporting/project_details.html.twig', [
|
||||
'project_view' => $projectView,
|
||||
'project_details' => $projectDetails,
|
||||
'form' => $form->createView(),
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -531,6 +531,20 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setOptions(['help' => 'help.theme.color_choices'])
|
||||
->setConstraints([new ColorChoices()])
|
||||
->setTranslationDomain('system-configuration'),
|
||||
// allow avatar URLs
|
||||
(new Configuration())
|
||||
->setName('theme.avatar_url')
|
||||
->setRequired(false)
|
||||
->setLabel('theme.avatar_url')
|
||||
->setType(CheckboxType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
// random colors as fallback
|
||||
(new Configuration())
|
||||
->setName('theme.random_colors')
|
||||
->setRequired(false)
|
||||
->setLabel('theme.random_colors')
|
||||
->setType(CheckboxType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_CALENDAR)
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
|
||||
namespace App\DependencyInjection\Compiler;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Configuration\ThemeConfiguration;
|
||||
use App\Twig\Configuration;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
|
||||
@@ -27,10 +27,11 @@ class TwigContextCompilerPass implements CompilerPassInterface
|
||||
{
|
||||
$twig = $container->getDefinition('twig');
|
||||
|
||||
// @deprecated since 1.15
|
||||
$theme = $container->getDefinition(ThemeConfiguration::class);
|
||||
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
|
||||
|
||||
$config = $container->getDefinition(SystemConfiguration::class);
|
||||
$config = $container->getDefinition(Configuration::class);
|
||||
$twig->addMethodCall('addGlobal', ['kimai_config', $config]);
|
||||
|
||||
$definition = $container->getDefinition('twig.loader.native_filesystem');
|
||||
|
||||
@@ -475,6 +475,12 @@ class Configuration implements ConfigurationInterface
|
||||
->integerNode('autocomplete_chars')
|
||||
->defaultValue(3)
|
||||
->end()
|
||||
->booleanNode('random_colors')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->booleanNode('avatar_url')
|
||||
->defaultFalse()
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
@@ -51,12 +51,9 @@ trait ColorTrait
|
||||
|
||||
/**
|
||||
* @param string $color
|
||||
* @return self
|
||||
*/
|
||||
public function setColor(?string $color = null)
|
||||
public function setColor(?string $color = null): void
|
||||
{
|
||||
$this->color = $color;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,8 @@ class Team
|
||||
*/
|
||||
private $activities;
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->users = new ArrayCollection();
|
||||
|
||||
@@ -280,6 +280,8 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
*/
|
||||
private $roles = [];
|
||||
|
||||
use ColorTrait;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->registeredAt = new DateTime();
|
||||
@@ -896,4 +898,40 @@ class User implements UserInterface, EquatableInterface, \Serializable
|
||||
{
|
||||
return $this->getDisplayName();
|
||||
}
|
||||
|
||||
public function getInitials(): string
|
||||
{
|
||||
$length = 2;
|
||||
|
||||
$name = $this->getDisplayName();
|
||||
$initial = '';
|
||||
|
||||
if (filter_var($name, FILTER_VALIDATE_EMAIL)) {
|
||||
// turn my.email@gmail.com into "My Email"
|
||||
$result = mb_strstr($name, '@', true);
|
||||
$name = $result === false ? $name : $result;
|
||||
$name = str_replace('.', ' ', $name);
|
||||
}
|
||||
|
||||
$words = explode(' ', $name);
|
||||
|
||||
// if name contains single word, use first N character
|
||||
if (\count($words) === 1) {
|
||||
$initial = $words[0];
|
||||
|
||||
if (mb_strlen($name) >= $length) {
|
||||
$initial = mb_substr($name, 0, $length, 'UTF-8');
|
||||
}
|
||||
} else {
|
||||
// otherwise, use initial char from each word
|
||||
foreach ($words as $word) {
|
||||
$initial .= mb_substr($word, 0, 1, 'UTF-8');
|
||||
}
|
||||
$initial = mb_substr($initial, 0, $length, 'UTF-8');
|
||||
}
|
||||
|
||||
$initial = mb_strtoupper($initial);
|
||||
|
||||
return $initial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,5 +71,9 @@ class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {
|
||||
$event->addDelete($this->path('admin_project_delete', ['id' => $project->getId()]));
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_reporting') && $this->isGranted('details_project')) {
|
||||
$event->addAction('report_project_details', ['url' => $this->path('report_project_details', ['project' => $project->getId()]), 'icon' => 'reporting', 'translation_domain' => 'reporting']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ActivityTeamPermissionForm extends AbstractType
|
||||
{
|
||||
use EntityFormTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
51
src/Form/ColorTrait.php
Normal file
51
src/Form/ColorTrait.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\ColorChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
|
||||
trait ColorTrait
|
||||
{
|
||||
protected function addColor(FormBuilderInterface $builder): void
|
||||
{
|
||||
$builder
|
||||
->add('color', ColorChoiceType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// this code exists only for backward compatibility
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event) {
|
||||
if (!$event->getForm()->getConfig()->hasOption('choices')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $event->getData();
|
||||
$choices = $event->getForm()->getConfig()->getOption('choices');
|
||||
if (\is_object($data) && method_exists($data, 'getColor')) {
|
||||
$color = $data->getColor();
|
||||
if (!empty($color) && array_search($color, $choices) === false) {
|
||||
$choices[$color] = $color;
|
||||
}
|
||||
}
|
||||
|
||||
$event->getForm()->add('color', ColorChoiceType::class, [
|
||||
'required' => false,
|
||||
'choices' => $choices,
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class CustomerTeamPermissionForm extends AbstractType
|
||||
{
|
||||
use EntityFormTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Type\ColorChoiceType;
|
||||
use App\Form\Type\DurationType;
|
||||
use App\Form\Type\MetaFieldsCollectionType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
|
||||
trait EntityFormTrait
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
public function addCommonFields(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$this->addColor($builder);
|
||||
@@ -50,39 +49,10 @@ trait EntityFormTrait
|
||||
]);
|
||||
}
|
||||
|
||||
public function addColor(FormBuilderInterface $builder): void
|
||||
{
|
||||
$builder
|
||||
->add('color', ColorChoiceType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// this code exists only for backward compatibility
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event) use ($builder) {
|
||||
if (!$builder->get('color')->hasOption('choices')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $event->getData();
|
||||
$choices = $builder->get('color')->getOption('choices');
|
||||
if (\is_object($data) && method_exists($data, 'getColor')) {
|
||||
$color = $data->getColor();
|
||||
if (!empty($color) && array_search($color, $choices) === false) {
|
||||
$choices[$color] = $color;
|
||||
}
|
||||
}
|
||||
|
||||
$event->getForm()->add('color', ColorChoiceType::class, [
|
||||
'required' => false,
|
||||
'choices' => $choices,
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 1.15
|
||||
* @param FormBuilderInterface $builder
|
||||
*/
|
||||
public function addCreateMore(FormBuilderInterface $builder): void
|
||||
{
|
||||
$builder->add('create_more', CheckboxType::class, [
|
||||
|
||||
@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ProjectTeamPermissionForm extends AbstractType
|
||||
{
|
||||
use EntityFormTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Tag;
|
||||
use App\Form\Type\ColorPickerType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -18,6 +17,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class TagEditForm extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -34,7 +35,8 @@ class TagEditForm extends AbstractType
|
||||
'description' => 'The tag name (forbidden character: comma)',
|
||||
],
|
||||
])
|
||||
->add('color', ColorPickerType::class);
|
||||
;
|
||||
$this->addColor($builder);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class TeamEditForm extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -37,7 +39,9 @@ class TeamEditForm extends AbstractType
|
||||
'type' => 'string',
|
||||
'description' => 'Name of the team',
|
||||
],
|
||||
])
|
||||
]);
|
||||
$this->addColor($builder);
|
||||
$builder
|
||||
->add('teamlead', UserType::class, [
|
||||
'label' => 'label.teamlead',
|
||||
'multiple' => false,
|
||||
|
||||
@@ -67,6 +67,7 @@ class ProjectType extends AbstractType
|
||||
'activity_select' => 'activity',
|
||||
'activity_visibility' => ActivityQuery::SHOW_VISIBLE,
|
||||
'ignore_date' => false,
|
||||
'join_customer' => false,
|
||||
]);
|
||||
|
||||
$resolver->setDefault('query_builder', function (Options $options) {
|
||||
@@ -78,6 +79,9 @@ class ProjectType extends AbstractType
|
||||
if (true === $options['ignore_date']) {
|
||||
$query->setIgnoreDate(true);
|
||||
}
|
||||
if (true === $options['join_customer']) {
|
||||
$query->setWithCustomer(true);
|
||||
}
|
||||
|
||||
return $repo->getQueryBuilderForFormType($query);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Form\Type\AvatarType;
|
||||
use App\Form\Type\LanguageType;
|
||||
@@ -25,6 +26,15 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
class UserEditType extends AbstractType
|
||||
{
|
||||
use ColorTrait;
|
||||
|
||||
private $configuration;
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -39,9 +49,17 @@ class UserEditType extends AbstractType
|
||||
'label' => 'label.title',
|
||||
'required' => false,
|
||||
])
|
||||
->add('avatar', AvatarType::class, [
|
||||
;
|
||||
|
||||
if ($this->configuration->isThemeAllowAvatarUrls()) {
|
||||
$builder->add('avatar', AvatarType::class, [
|
||||
'required' => false,
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addColor($builder);
|
||||
|
||||
$builder
|
||||
->add('email', EmailType::class, [
|
||||
'label' => 'label.email',
|
||||
])
|
||||
|
||||
44
src/Migrations/Version20210704111542.php
Normal file
44
src/Migrations/Version20210704111542.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20210704111542 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Creates the color columns on: user, team';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$users = $schema->getTable('kimai2_users');
|
||||
$users->addColumn('color', 'string', ['length' => 7, 'notnull' => false, 'default' => null]);
|
||||
|
||||
$teams = $schema->getTable('kimai2_teams');
|
||||
$teams->addColumn('color', 'string', ['length' => 7, 'notnull' => false, 'default' => null]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$users = $schema->getTable('kimai2_users');
|
||||
$users->dropColumn('color');
|
||||
|
||||
$teams = $schema->getTable('kimai2_teams');
|
||||
$teams->dropColumn('color');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,58 @@
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
class ActivityStatistic extends TimesheetCountedStatistic
|
||||
use App\Entity\Activity;
|
||||
|
||||
class ActivityStatistic extends TimesheetCountedStatistic implements \JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @var Activity
|
||||
*/
|
||||
private $activity;
|
||||
|
||||
public function getActivity(): ?Activity
|
||||
{
|
||||
return $this->activity;
|
||||
}
|
||||
|
||||
public function setActivity(Activity $activity): void
|
||||
{
|
||||
$this->activity = $activity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Added for simpler re-use in frontend (charts).
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getColor(): ?string
|
||||
{
|
||||
if ($this->activity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->activity->getColor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Added for simpler re-use in frontend (charts).
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getName(): ?string
|
||||
{
|
||||
if ($this->activity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->activity->getName();
|
||||
}
|
||||
|
||||
public function jsonSerialize()
|
||||
{
|
||||
return array_merge(parent::jsonSerialize(), [
|
||||
'name' => $this->getName(),
|
||||
'color' => $this->getColor(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,20 +11,12 @@ namespace App\Model\Statistic;
|
||||
|
||||
use DateTime;
|
||||
|
||||
class Day
|
||||
class Day extends Timesheet
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $totalDuration = 0;
|
||||
/**
|
||||
* @var int|null
|
||||
*/
|
||||
private $totalDurationBillable = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
protected $totalRate = 0.00;
|
||||
/**
|
||||
* @var DateTime
|
||||
*/
|
||||
@@ -37,8 +29,8 @@ class Day
|
||||
public function __construct(DateTime $day, int $duration, float $rate)
|
||||
{
|
||||
$this->day = $day;
|
||||
$this->totalDuration = $duration;
|
||||
$this->totalRate = $rate;
|
||||
$this->setTotalDuration($duration);
|
||||
$this->setTotalRate($rate);
|
||||
}
|
||||
|
||||
public function getDay(): DateTime
|
||||
@@ -46,18 +38,6 @@ class Day
|
||||
return $this->day;
|
||||
}
|
||||
|
||||
public function getTotalDuration(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
|
||||
public function setTotalDuration(int $seconds): Day
|
||||
{
|
||||
$this->totalDuration = $seconds;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDurationBillable(): int
|
||||
{
|
||||
return $this->totalDurationBillable;
|
||||
@@ -68,18 +48,6 @@ class Day
|
||||
$this->totalDurationBillable = $seconds;
|
||||
}
|
||||
|
||||
public function getTotalRate(): float
|
||||
{
|
||||
return $this->totalRate;
|
||||
}
|
||||
|
||||
public function setTotalRate(float $totalRate): Day
|
||||
{
|
||||
$this->totalRate = $totalRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDetails(array $details): Day
|
||||
{
|
||||
$this->details = $details;
|
||||
|
||||
@@ -11,11 +11,9 @@ namespace App\Model\Statistic;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class Month
|
||||
final class Month extends Timesheet
|
||||
{
|
||||
private $month;
|
||||
private $totalDuration = 0;
|
||||
private $totalRate = 0.00;
|
||||
private $billableDuration = 0;
|
||||
private $billableRate = 0.00;
|
||||
|
||||
@@ -40,30 +38,6 @@ final class Month
|
||||
return (int) $this->month;
|
||||
}
|
||||
|
||||
public function getTotalDuration(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
|
||||
public function setTotalDuration(int $seconds): Month
|
||||
{
|
||||
$this->totalDuration = $seconds;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalRate(): float
|
||||
{
|
||||
return $this->totalRate;
|
||||
}
|
||||
|
||||
public function setTotalRate(float $totalRate): Month
|
||||
{
|
||||
$this->totalRate = $totalRate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
return $this->billableDuration;
|
||||
|
||||
87
src/Model/Statistic/Timesheet.php
Normal file
87
src/Model/Statistic/Timesheet.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?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\Model\Statistic;
|
||||
|
||||
class Timesheet
|
||||
{
|
||||
private $totalDuration = 0;
|
||||
private $totalRate = 0.00;
|
||||
private $totalInternalRate = 0.00;
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getValue(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getDuration(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
|
||||
public function getTotalDuration(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
}
|
||||
|
||||
public function setTotalDuration(int $totalDuration): void
|
||||
{
|
||||
$this->totalDuration = $totalDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function getRate(): float
|
||||
{
|
||||
return $this->totalRate;
|
||||
}
|
||||
|
||||
public function getTotalRate(): float
|
||||
{
|
||||
return $this->totalRate;
|
||||
}
|
||||
|
||||
public function setTotalRate(float $totalRate): void
|
||||
{
|
||||
$this->totalRate = $totalRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function getInternalRate(): float
|
||||
{
|
||||
return $this->totalInternalRate;
|
||||
}
|
||||
|
||||
public function getTotalInternalRate(): float
|
||||
{
|
||||
return $this->totalInternalRate;
|
||||
}
|
||||
|
||||
public function setTotalInternalRate(float $totalInternalRate): void
|
||||
{
|
||||
$this->totalInternalRate = $totalInternalRate;
|
||||
}
|
||||
}
|
||||
60
src/Model/Statistic/UserYear.php
Normal file
60
src/Model/Statistic/UserYear.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?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\Model\Statistic;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
final class UserYear
|
||||
{
|
||||
/**
|
||||
* @var Year
|
||||
*/
|
||||
private $year;
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
public function __construct(User $user, Year $year)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->year = $year;
|
||||
}
|
||||
|
||||
public function getYear(): Year
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getDuration(): int
|
||||
{
|
||||
$duration = 0;
|
||||
foreach ($this->year->getMonths() as $month) {
|
||||
$duration += $month->getDuration();
|
||||
}
|
||||
|
||||
return $duration;
|
||||
}
|
||||
|
||||
public function getRate(): float
|
||||
{
|
||||
$rate = 0;
|
||||
foreach ($this->year->getMonths() as $month) {
|
||||
$rate += $month->getRate();
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,13 @@
|
||||
|
||||
namespace App\Model\Statistic;
|
||||
|
||||
/**
|
||||
* Yearly statistics
|
||||
*/
|
||||
class Year
|
||||
final class Year extends Timesheet
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $year;
|
||||
private $year;
|
||||
/**
|
||||
* @var Month[]
|
||||
*/
|
||||
protected $months = [];
|
||||
private $months = [];
|
||||
|
||||
public function __construct(string $year)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
class TimesheetCountedStatistic
|
||||
class TimesheetCountedStatistic implements \JsonSerializable
|
||||
{
|
||||
private $recordAmount = 0;
|
||||
private $recordDuration = 0;
|
||||
@@ -40,6 +40,26 @@ class TimesheetCountedStatistic
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getValue(): int
|
||||
{
|
||||
return $this->recordDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function getDuration(): int
|
||||
{
|
||||
return $this->recordDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total duration of all included timesheet records.
|
||||
*
|
||||
@@ -61,6 +81,16 @@ class TimesheetCountedStatistic
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
public function getRate(): float
|
||||
{
|
||||
return $this->recordRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total rate of all included timesheet records.
|
||||
*
|
||||
@@ -132,4 +162,17 @@ class TimesheetCountedStatistic
|
||||
{
|
||||
$this->recordRateBillable = $recordRate;
|
||||
}
|
||||
|
||||
public function jsonSerialize()
|
||||
{
|
||||
return [
|
||||
'duration' => $this->recordDuration,
|
||||
'duration_billable' => $this->recordDurationBillable,
|
||||
'rate' => $this->recordRate,
|
||||
'rate_billable' => $this->recordRateBillable,
|
||||
'rate_internal' => $this->recordInternalRate,
|
||||
'amount' => $this->recordAmount,
|
||||
'amount_billable' => $this->recordAmountBillable,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
40
src/Model/UserStatistic.php
Normal file
40
src/Model/UserStatistic.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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\Model;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\Statistic\Month;
|
||||
|
||||
class UserStatistic extends TimesheetCountedStatistic
|
||||
{
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
public function __construct(User $user)
|
||||
{
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function addValuesFromMonth(Month $month): void
|
||||
{
|
||||
$this->setRecordDuration($this->getRecordDuration() + $month->getTotalDuration());
|
||||
$this->setDurationBillable($this->getDurationBillable() + $month->getBillableDuration());
|
||||
$this->setRecordRate($this->getRate() + $month->getTotalRate());
|
||||
$this->setRateBillable($this->getRateBillable() + $month->getBillableRate());
|
||||
$this->setRecordInternalRate($this->getRecordInternalRate() + $month->getTotalInternalRate());
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,28 @@
|
||||
|
||||
namespace App\Project;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Event\ProjectStatisticEvent;
|
||||
use App\Model\ActivityStatistic;
|
||||
use App\Model\ProjectStatistic;
|
||||
use App\Model\Statistic\Month;
|
||||
use App\Model\Statistic\Year;
|
||||
use App\Model\UserStatistic;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsModel;
|
||||
use App\Reporting\ProjectDetails\ProjectDetailsQuery;
|
||||
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
|
||||
use App\Reporting\ProjectView\ProjectViewModel;
|
||||
use App\Reporting\ProjectView\ProjectViewQuery;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
@@ -32,12 +41,14 @@ class ProjectStatisticService
|
||||
private $repository;
|
||||
private $timesheetRepository;
|
||||
private $dispatcher;
|
||||
private $userRepository;
|
||||
|
||||
public function __construct(ProjectRepository $projectRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(ProjectRepository $projectRepository, TimesheetRepository $timesheetRepository, EventDispatcherInterface $dispatcher, UserRepository $userRepository)
|
||||
{
|
||||
$this->repository = $projectRepository;
|
||||
$this->timesheetRepository = $timesheetRepository;
|
||||
$this->dispatcher = $dispatcher;
|
||||
$this->userRepository = $userRepository;
|
||||
}
|
||||
|
||||
public function getProjectStatistics(Project $project, ?DateTime $end = null): ProjectStatistic
|
||||
@@ -89,6 +100,137 @@ class ProjectStatisticService
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectDetailsQuery $query
|
||||
* @return ProjectDetailsModel
|
||||
*/
|
||||
public function getProjectsDetails(ProjectDetailsQuery $query): ProjectDetailsModel
|
||||
{
|
||||
$model = new ProjectDetailsModel($query->getProject());
|
||||
|
||||
$years = [];
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('SUM(t.duration) as duration')
|
||||
->addSelect('SUM(t.rate) as rate')
|
||||
->addSelect('SUM(t.internalRate) as internalRate')
|
||||
->addSelect('COUNT(t.id) as count')
|
||||
->andWhere('t.project = :project')
|
||||
->setParameter('project', $query->getProject())
|
||||
;
|
||||
|
||||
// fetch stats grouped by ACTIVITY for all time
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
|
||||
->addSelect('a as activity')
|
||||
->addGroupBy('a')
|
||||
;
|
||||
foreach ($qb1->getQuery()->getResult() as $tmp) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activity->setRecordRate($tmp['rate']);
|
||||
$activity->setRecordDuration($tmp['duration']);
|
||||
$activity->setRecordInternalRate($tmp['internalRate']);
|
||||
$activity->setRecordAmount($tmp['count']);
|
||||
$model->addActivity($activity);
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
// fetch stats grouped by YEAR, MONTH and USER
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->addSelect('YEAR(t.begin) as year')
|
||||
->addSelect('MONTH(t.begin) as month')
|
||||
->addSelect('IDENTITY(t.user) as user')
|
||||
->addGroupBy('year')
|
||||
->addGroupBy('month')
|
||||
->addGroupBy('user')
|
||||
;
|
||||
|
||||
$userMonths = $qb1->getQuery()->getResult();
|
||||
$userIds = array_unique(array_column($userMonths, 'user'));
|
||||
|
||||
$qb2 = $this->userRepository->createQueryBuilder('u');
|
||||
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
|
||||
$users = [];
|
||||
foreach ($qb2->getQuery()->getResult() as $user) {
|
||||
$users[$user->getId()] = new UserStatistic($user);
|
||||
}
|
||||
|
||||
foreach ($userMonths as $tmp) {
|
||||
$user = $users[$tmp['user']]->getUser();
|
||||
$year = $model->getUserYear($tmp['year'], $user);
|
||||
if ($year === null) {
|
||||
$year = new Year($tmp['year']);
|
||||
$model->setUserYear($year, $user);
|
||||
}
|
||||
$month = new Month($tmp['month']);
|
||||
$month->setTotalRate($tmp['rate']);
|
||||
$month->setTotalDuration($tmp['duration']);
|
||||
$month->setTotalInternalRate($tmp['internalRate']);
|
||||
$year->setMonth($month);
|
||||
$users[$tmp['user']]->addValuesFromMonth($month);
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
// fetch stats grouped by YEARS
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->addSelect('YEAR(t.begin) as year')
|
||||
->addGroupBy('year')
|
||||
;
|
||||
foreach ($qb1->getQuery()->getResult() as $year) {
|
||||
$tmp = new Year($year['year']);
|
||||
$tmp->setTotalRate($year['rate']);
|
||||
$tmp->setTotalInternalRate($year['internalRate']);
|
||||
$tmp->setTotalDuration($year['duration']);
|
||||
$years[$year['year']] = $tmp;
|
||||
|
||||
// fetch yearly stats grouped by ACTIVITY and YEAR
|
||||
$qb2 = clone $qb;
|
||||
$qb2
|
||||
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
|
||||
->addSelect('a as activity')
|
||||
->addSelect('YEAR(t.begin) as year')
|
||||
->andWhere('YEAR(t.begin) = :year')
|
||||
->setParameter('year', $year['year'])
|
||||
->addGroupBy('year')
|
||||
->addGroupBy('a')
|
||||
;
|
||||
foreach ($qb2->getQuery()->getResult() as $tmp) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activity->setRecordRate($tmp['rate']);
|
||||
$activity->setRecordDuration($tmp['duration']);
|
||||
$activity->setRecordInternalRate($tmp['internalRate']);
|
||||
$activity->setRecordAmount($tmp['count']);
|
||||
$model->addYearActivity($tmp['year'], $activity);
|
||||
}
|
||||
}
|
||||
$model->setYears(array_values($years));
|
||||
// ---------------------------------------------------
|
||||
|
||||
// fetch stats grouped by MONTH and YEAR
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->addSelect('YEAR(t.begin) as year')
|
||||
->addSelect('MONTH(t.begin) as month')
|
||||
->addGroupBy('year')
|
||||
->addGroupBy('month')
|
||||
;
|
||||
foreach ($qb1->getQuery()->getResult() as $month) {
|
||||
$tmp = new Month($month['month']);
|
||||
$tmp->setTotalRate($month['rate']);
|
||||
$tmp->setTotalInternalRate($month['internalRate']);
|
||||
$tmp->setTotalDuration($month['duration']);
|
||||
$model->getYear($month['year'])->setMonth($tmp);
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectViewQuery $query
|
||||
* @return Project[]
|
||||
|
||||
53
src/Reporting/ProjectDetails/ProjectDetailsForm.php
Normal file
53
src/Reporting/ProjectDetails/ProjectDetailsForm.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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\Reporting\ProjectDetails;
|
||||
|
||||
use App\Form\Type\ProjectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ProjectDetailsForm extends AbstractType
|
||||
{
|
||||
/**
|
||||
* Simplify cross linking between pages by removing the block prefix.
|
||||
*
|
||||
* @return null|string
|
||||
*/
|
||||
public function getBlockPrefix()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->add('project', ProjectType::class, [
|
||||
'required' => false,
|
||||
'label' => false,
|
||||
'width' => false,
|
||||
'join_customer' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ProjectDetailsQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'method' => 'GET',
|
||||
]);
|
||||
}
|
||||
}
|
||||
158
src/Reporting/ProjectDetails/ProjectDetailsModel.php
Normal file
158
src/Reporting/ProjectDetails/ProjectDetailsModel.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?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\Reporting\ProjectDetails;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use App\Model\ActivityStatistic;
|
||||
use App\Model\Statistic\UserYear;
|
||||
use App\Model\Statistic\Year;
|
||||
use App\Model\UserStatistic;
|
||||
|
||||
final class ProjectDetailsModel
|
||||
{
|
||||
/**
|
||||
* @var Project
|
||||
*/
|
||||
private $project;
|
||||
/**
|
||||
* @var Year[]
|
||||
*/
|
||||
private $years = [];
|
||||
/**
|
||||
* @var array<string, array<ActivityStatistic>>
|
||||
*/
|
||||
private $yearlyActivities = [];
|
||||
/**
|
||||
* @var array<string, array<int, UserYear>>
|
||||
*/
|
||||
private $usersMonthly = [];
|
||||
/**
|
||||
* @var ActivityStatistic[]
|
||||
*/
|
||||
private $activities = [];
|
||||
|
||||
public function __construct(Project $project)
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
|
||||
public function getProject(): Project
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function addActivity(ActivityStatistic $activityStatistic): void
|
||||
{
|
||||
$this->activities[$activityStatistic->getActivity()->getId()] = $activityStatistic;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ActivityStatistic[]
|
||||
*/
|
||||
public function getActivities(): array
|
||||
{
|
||||
return array_values($this->activities);
|
||||
}
|
||||
|
||||
public function addYearActivity(string $year, ActivityStatistic $activityStatistic): void
|
||||
{
|
||||
$this->yearlyActivities[$year][] = $activityStatistic;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $year
|
||||
* @return ActivityStatistic[]
|
||||
*/
|
||||
public function getYearActivities(string $year): ?array
|
||||
{
|
||||
if (!\array_key_exists($year, $this->yearlyActivities)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->yearlyActivities[$year];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserStatistic[]
|
||||
*/
|
||||
public function getUserStats(): array
|
||||
{
|
||||
$users = [];
|
||||
foreach ($this->usersMonthly as $year) {
|
||||
foreach ($year as $id => $userYear) {
|
||||
if (\array_key_exists($id, $users)) {
|
||||
$userStat = $users[$id];
|
||||
} else {
|
||||
$userStat = new UserStatistic($userYear->getUser());
|
||||
$users[$id] = $userStat;
|
||||
}
|
||||
$userStat->setRecordDuration($userStat->getRecordDuration() + $userYear->getDuration());
|
||||
$userStat->setRecordRate($userStat->getRecordRate() + $userYear->getRate());
|
||||
}
|
||||
}
|
||||
|
||||
return $users;
|
||||
}
|
||||
|
||||
public function setUserYear(Year $year, User $user): void
|
||||
{
|
||||
$this->usersMonthly[$year->getYear()][$user->getId()] = new UserYear($user, $year);
|
||||
}
|
||||
|
||||
public function getUserYear(string $year, User $user): ?Year
|
||||
{
|
||||
if (!\array_key_exists($year, $this->usersMonthly) || !\array_key_exists($user->getId(), $this->usersMonthly[$year])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->usersMonthly[$year][$user->getId()]->getYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $year
|
||||
* @return UserYear[]
|
||||
*/
|
||||
public function getUserYears(string $year): array
|
||||
{
|
||||
if (!\array_key_exists($year, $this->usersMonthly)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->usersMonthly[$year];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Year[]
|
||||
*/
|
||||
public function getYears(): array
|
||||
{
|
||||
return $this->years;
|
||||
}
|
||||
|
||||
public function getYear(string $year): ?Year
|
||||
{
|
||||
foreach ($this->years as $tmp) {
|
||||
if ($tmp->getYear() === $year) {
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Year[] $years
|
||||
*/
|
||||
public function setYears(array $years): void
|
||||
{
|
||||
$this->years = $years;
|
||||
}
|
||||
}
|
||||
56
src/Reporting/ProjectDetails/ProjectDetailsQuery.php
Normal file
56
src/Reporting/ProjectDetails/ProjectDetailsQuery.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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\Reporting\ProjectDetails;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
use DateTime;
|
||||
|
||||
final class ProjectDetailsQuery
|
||||
{
|
||||
/**
|
||||
* @var Project|null
|
||||
*/
|
||||
private $project;
|
||||
/**
|
||||
* @var DateTime
|
||||
*/
|
||||
private $today;
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
private $user;
|
||||
|
||||
public function __construct(DateTime $today, User $user)
|
||||
{
|
||||
$this->today = $today;
|
||||
$this->user = $user;
|
||||
}
|
||||
|
||||
public function getToday(): DateTime
|
||||
{
|
||||
return $this->today;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getProject(): ?Project
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function setProject(?Project $project): void
|
||||
{
|
||||
$this->project = $project;
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,9 @@ final class ReportingService
|
||||
}
|
||||
if ($this->security->isGranted('budget_project')) {
|
||||
$event->addReport(new Report('project_view', 'report_project_view', 'report_project_view', 'project'));
|
||||
if ($this->security->isGranted('details_project')) {
|
||||
$event->addReport(new Report('project_details', 'report_project_details', 'report_project_details', 'project'));
|
||||
}
|
||||
$event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,10 @@ class ProjectRepository extends EntityRepository
|
||||
->addOrderBy('p.name', 'ASC')
|
||||
;
|
||||
|
||||
if ($query->withCustomer()) {
|
||||
$qb->addSelect('c');
|
||||
}
|
||||
|
||||
$qb->andWhere($qb->expr()->eq('p.visible', ':visible'));
|
||||
$qb->andWhere($qb->expr()->eq('c.visible', ':customer_visible'));
|
||||
|
||||
|
||||
@@ -18,10 +18,8 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
* @var Project|null
|
||||
*/
|
||||
private $projectToIgnore;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $ignoreDate = false;
|
||||
private $withCustomer = false;
|
||||
|
||||
/**
|
||||
* @param Project|int|null $project
|
||||
@@ -44,6 +42,16 @@ final class ProjectFormTypeQuery extends BaseFormTypeQuery
|
||||
}
|
||||
}
|
||||
|
||||
public function withCustomer(): bool
|
||||
{
|
||||
return $this->withCustomer;
|
||||
}
|
||||
|
||||
public function setWithCustomer(bool $withCustomer): void
|
||||
{
|
||||
$this->withCustomer = $withCustomer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Project|null
|
||||
*/
|
||||
|
||||
@@ -1,59 +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\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, 'avatars');
|
||||
}
|
||||
}
|
||||
67
src/Twig/Configuration.php
Normal file
67
src/Twig/Configuration.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?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\Configuration\SystemConfiguration;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
final class Configuration extends AbstractExtension
|
||||
{
|
||||
private $configuration;
|
||||
private $cache = [];
|
||||
|
||||
public function __construct(SystemConfiguration $configuration)
|
||||
{
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('config', [$this, 'get']),
|
||||
];
|
||||
}
|
||||
|
||||
public function get(string $name)
|
||||
{
|
||||
if (\array_key_exists($name, $this->cache)) {
|
||||
return $this->cache[$name];
|
||||
}
|
||||
|
||||
$value = $this->configuration->find($name);
|
||||
$this->cache[$name] = $value;
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
public function __call($name, $arguments)
|
||||
{
|
||||
if (\array_key_exists($name, $this->cache)) {
|
||||
return $this->cache[$name];
|
||||
}
|
||||
|
||||
$checks = ['is' . $name, 'get' . $name, 'has' . $name, $name];
|
||||
|
||||
foreach ($checks as $methodName) {
|
||||
if (method_exists($this->configuration, $methodName)) {
|
||||
$value = \call_user_func([$this->configuration, $methodName], $arguments);
|
||||
$this->cache[$name] = $value;
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->configuration->find($name);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class Extensions extends AbstractExtension
|
||||
new TwigFilter('multiline_indent', [$this, 'multilineIndent']),
|
||||
new TwigFilter('color', [$this, 'color']),
|
||||
new TwigFilter('font_contrast', [$this, 'calculateFontContrastColor']),
|
||||
new TwigFilter('default_color', [$this, 'defaultColor']),
|
||||
new TwigFilter('nl2str', [$this, 'replaceNewline'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
|
||||
];
|
||||
}
|
||||
@@ -43,6 +44,7 @@ class Extensions extends AbstractExtension
|
||||
return [
|
||||
new TwigFunction('class_name', [$this, 'getClassName']),
|
||||
new TwigFunction('iso_day_by_name', [$this, 'getIsoDayByName']),
|
||||
new TwigFunction('random_color', [$this, 'randomColor']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -71,11 +73,21 @@ class Extensions extends AbstractExtension
|
||||
return (new Color())->getColor($entity, $defaultColor);
|
||||
}
|
||||
|
||||
public function randomColor(?string $input = null): string
|
||||
{
|
||||
return (new Color())->getRandom($input);
|
||||
}
|
||||
|
||||
public function calculateFontContrastColor(string $color): string
|
||||
{
|
||||
return (new Color())->getFontContrastColor($color);
|
||||
}
|
||||
|
||||
public function defaultColor(?string $color = null): string
|
||||
{
|
||||
return $color ?? Constants::DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param object $object
|
||||
* @return null|string
|
||||
|
||||
@@ -59,6 +59,7 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
new TwigFilter('time', [$this, 'time']),
|
||||
new TwigFilter('hour24', [$this, 'hour24']),
|
||||
new TwigFilter('duration', [$this, 'duration']),
|
||||
new TwigFilter('chart_duration', [$this, 'durationChart']),
|
||||
new TwigFilter('duration_decimal', [$this, 'durationDecimal']),
|
||||
new TwigFilter('money', [$this, 'money']),
|
||||
new TwigFilter('currency', [$this, 'currency']),
|
||||
@@ -91,6 +92,7 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
new TwigFunction('get_format_duration', [$this, 'getDurationFormat']),
|
||||
new TwigFunction('create_date', [$this, 'createDate']),
|
||||
new TwigFunction('locales', [$this, 'getLocales']),
|
||||
new TwigFunction('month_names', [$this, 'getMonthNames']),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -187,6 +189,25 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
return $this->getFormatter()->time($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $year
|
||||
* @return string[]
|
||||
*/
|
||||
public function getMonthNames(?string $year = null): array
|
||||
{
|
||||
$withYear = true;
|
||||
if ($year === null) {
|
||||
$year = date('Y');
|
||||
$withYear = false;
|
||||
}
|
||||
$months = [];
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
$months[] = $this->getFormatter()->monthName(new DateTime(sprintf('%s-%s-10', $year, ($i < 10 ? '0' . $i : (string) $i))), $withYear);
|
||||
}
|
||||
|
||||
return $months;
|
||||
}
|
||||
|
||||
public function monthName(\DateTime $dateTime, bool $withYear = false): string
|
||||
{
|
||||
return $this->getFormatter()->monthName($dateTime, $withYear);
|
||||
@@ -235,6 +256,11 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
return $this->getFormatter()->durationDecimal($duration);
|
||||
}
|
||||
|
||||
public function durationChart($duration): string
|
||||
{
|
||||
return number_format(($duration / 3600), 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|float $amount
|
||||
* @return bool|false|string
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Entity\User;
|
||||
use App\Event\PageActionsEvent;
|
||||
use App\Event\ThemeEvent;
|
||||
use App\Event\ThemeJavascriptTranslationsEvent;
|
||||
use App\Utils\Color;
|
||||
use Symfony\Bridge\Twig\AppVariable;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
@@ -26,6 +27,10 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
private $eventDispatcher;
|
||||
private $translator;
|
||||
private $configuration;
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $randomColors;
|
||||
|
||||
public function __construct(EventDispatcherInterface $dispatcher, TranslatorInterface $translator, SystemConfiguration $configuration)
|
||||
{
|
||||
@@ -135,4 +140,25 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
|
||||
return $this->configuration->find($name);
|
||||
}
|
||||
|
||||
public function colorize(?string $color, ?string $identifier = null, ?string $fallback = null): string
|
||||
{
|
||||
if ($color !== null) {
|
||||
return $color;
|
||||
}
|
||||
|
||||
if ($this->randomColors === null) {
|
||||
$this->randomColors = $this->configuration->isThemeRandomColors();
|
||||
}
|
||||
|
||||
if ($this->randomColors) {
|
||||
return (new Color())->getRandom($identifier);
|
||||
}
|
||||
|
||||
if ($fallback !== null) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return Constants::DEFAULT_COLOR;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ class RuntimeExtensions extends AbstractExtension
|
||||
new TwigFilter('desc2html', [MarkdownExtension::class, 'timesheetContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
|
||||
new TwigFilter('comment2html', [MarkdownExtension::class, 'commentContent'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
|
||||
new TwigFilter('comment1line', [MarkdownExtension::class, 'commentOneLiner'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
|
||||
new TwigFilter('colorize', [ThemeExtension::class, 'colorize']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +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\Utils;
|
||||
|
||||
use App\Entity\User;
|
||||
use Laravolt\Avatar\Avatar;
|
||||
use Laravolt\Avatar\Generator\DefaultGenerator;
|
||||
|
||||
class AvatarService
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $directory;
|
||||
|
||||
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->setStorageDirectory($projectDirectory . '/public/avatars/');
|
||||
}
|
||||
|
||||
public function getStorageDirectory(): string
|
||||
{
|
||||
return $this->directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @CloudRequired
|
||||
*/
|
||||
public function setStorageDirectory(string $directory)
|
||||
{
|
||||
$this->directory = realpath($directory);
|
||||
}
|
||||
|
||||
private function getAvatarUrl(User $profile): string
|
||||
{
|
||||
return md5($profile->getId() . '_' . $profile->getDisplayName()) . '.png';
|
||||
}
|
||||
|
||||
private function getImagePath(User $profile): string
|
||||
{
|
||||
return $this->getStorageDirectory() . '/' . $this->getAvatarUrl($profile);
|
||||
}
|
||||
|
||||
public function generateAvatar(User $profile, bool $regenerate = false): bool
|
||||
{
|
||||
if (!$this->hasDependencies()) {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function hasDependencies(): bool
|
||||
{
|
||||
return \extension_loaded('gd') && \function_exists('imagettfbbox');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,13 @@ use App\Entity\Timesheet;
|
||||
|
||||
final class Color
|
||||
{
|
||||
// @see https://clrs.cc
|
||||
public const PALETTE_1 = ['#0074D9', '#7FDBFF', '#39CCCC', '#B10DC9', '#F012BE', '#85144b', '#FF4136', '#FF851B', '#FFDC00', '#3D9970', '#2ECC40', '#01FF70', '#AAAAAA', '#DDDDDD'];
|
||||
// old avatar color set
|
||||
public const PALETTE_2 = ['#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'];
|
||||
// all mixed together
|
||||
public const PALETTE_3 = ['#AAAAAA', '#DDDDDD', '#a972c9', '#9C27B0', '#673AB7', '#041fd1', '#5319e7', '#3F51B5', '#0074D9', '#2196F3', '#03A9F4', '#7FDBFF', '#39CCCC', '#00BCD4', '#006b75', '#009688', '#00bb32', '#4CAF50', '#3D9970', '#2ECC40', '#01FF70', '#8BC34A', '#CDDC39', '#FFDC00', '#FFC107', '#FF851B', '#FF9800', '#FF5722', '#f41a00', '#E91E63', '#85144b', '#b60205', '#FF4136', '#cc317c', '#F012BE', '#d82d80', '#B10DC9', '#e135f4', '#2d3748', '#4a5568', '#718096'];
|
||||
|
||||
public function getTimesheetColor(Timesheet $timesheet): string
|
||||
{
|
||||
$activity = $timesheet->getActivity();
|
||||
@@ -76,6 +83,33 @@ final class Color
|
||||
return $defaultColor ? Constants::DEFAULT_COLOR : null;
|
||||
}
|
||||
|
||||
public function getRandom(?string $input = null): string
|
||||
{
|
||||
if ($input === null) {
|
||||
return $this->getRandomColor();
|
||||
}
|
||||
|
||||
return $this->getRandomFromPalette($input);
|
||||
}
|
||||
|
||||
public function getRandomColor(): string
|
||||
{
|
||||
return sprintf('#%06x', rand(0, 16777215));
|
||||
}
|
||||
|
||||
public function getRandomFromPalette(string $input): string
|
||||
{
|
||||
$id = 0;
|
||||
for ($pos = 0; $pos < \strlen($input); $pos++) {
|
||||
$id += mb_ord($input[$pos], 'UTF-8');
|
||||
}
|
||||
|
||||
$colors = self::PALETTE_3;
|
||||
$key = $id % \count($colors);
|
||||
|
||||
return $colors[$key];
|
||||
}
|
||||
|
||||
public function getFontContrastColor(string $color): string
|
||||
{
|
||||
if (empty($color) || $color[0] !== '#') {
|
||||
|
||||
33
symfony.lock
33
symfony.lock
@@ -141,30 +141,9 @@
|
||||
"gedmo/doctrine-extensions": {
|
||||
"version": "v2.4.36"
|
||||
},
|
||||
"guzzlehttp/psr7": {
|
||||
"version": "1.6.1"
|
||||
},
|
||||
"handcraftedinthealps/rest-routing-bundle": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"illuminate/cache": {
|
||||
"version": "v6.0.4"
|
||||
},
|
||||
"illuminate/collections": {
|
||||
"version": "v8.44.0"
|
||||
},
|
||||
"illuminate/contracts": {
|
||||
"version": "v6.0.4"
|
||||
},
|
||||
"illuminate/macroable": {
|
||||
"version": "v8.44.0"
|
||||
},
|
||||
"illuminate/support": {
|
||||
"version": "v6.0.4"
|
||||
},
|
||||
"intervention/image": {
|
||||
"version": "2.5.0"
|
||||
},
|
||||
"jms/metadata": {
|
||||
"version": "1.6.0"
|
||||
},
|
||||
@@ -195,9 +174,6 @@
|
||||
"laminas/laminas-zendframework-bridge": {
|
||||
"version": "1.1.1"
|
||||
},
|
||||
"laravolt/avatar": {
|
||||
"version": "3.0.0"
|
||||
},
|
||||
"league/csv": {
|
||||
"version": "9.4.1"
|
||||
},
|
||||
@@ -246,9 +222,6 @@
|
||||
"ref": "f0436fc35fca88eada758311f8de43bfb61f1980"
|
||||
}
|
||||
},
|
||||
"nesbot/carbon": {
|
||||
"version": "2.24.0"
|
||||
},
|
||||
"nikic/php-parser": {
|
||||
"version": "v4.0.2"
|
||||
},
|
||||
@@ -357,9 +330,6 @@
|
||||
"psr/simple-cache": {
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"ralouphie/getallheaders": {
|
||||
"version": "3.0.3"
|
||||
},
|
||||
"robrichards/xmlseclibs": {
|
||||
"version": "3.0.4"
|
||||
},
|
||||
@@ -798,9 +768,6 @@
|
||||
"twig/twig": {
|
||||
"version": "v2.4.4"
|
||||
},
|
||||
"voku/portable-ascii": {
|
||||
"version": "1.4.9"
|
||||
},
|
||||
"webmozart/assert": {
|
||||
"version": "1.2.0"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
|
||||
{% for entry in entries %}
|
||||
<tr {{ widgets.activity_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
|
||||
{{ widgets.label_name(entry.name, entry.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
|
||||
{# only none-global activities have a project and customer assigned #}
|
||||
{% if entry.project %}
|
||||
|
||||
@@ -67,16 +67,18 @@
|
||||
{% block page_subtitle %}{% endblock %}
|
||||
|
||||
{% block logo_mini %}
|
||||
{% if not kimai_context.branding.mini is empty %}
|
||||
{{ kimai_context.branding.mini|raw }}
|
||||
{% set mini = config('theme.branding.mini') %}
|
||||
{% if mini is not empty %}
|
||||
{{ mini|raw }}
|
||||
{% else %}
|
||||
<b>K</b>TT
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block logo_large %}
|
||||
{% if not kimai_context.branding.company is empty %}
|
||||
{{ kimai_context.branding.company|raw }}
|
||||
{% set company = config('theme.branding.company') %}
|
||||
{% if company is not empty %}
|
||||
{{ company|raw }}
|
||||
{% else %}
|
||||
<b>Kimai</b> - Time Tracking
|
||||
{% endif %}
|
||||
@@ -230,7 +232,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if kimai_context.show_about %}
|
||||
{% if config('theme.show_about') %}
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<a href="{{ path('about') }}">
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
callback(apiEvents);
|
||||
});
|
||||
},
|
||||
color: '{{ kimai_context.calendar.background_color }}',
|
||||
color: '{{ config('theme.calendar.background_color') }}',
|
||||
name: 'kimaiUserTimeSource'
|
||||
}
|
||||
{% if google is not null %}
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
|
||||
{% for entry in entries %}
|
||||
<tr {{ widgets.customer_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
|
||||
{{ widgets.label_name(entry.name, entry.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'company') }}">{{ entry.company }}</td>
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td align="right">
|
||||
{% if kimai_context.branding.company is not empty %}
|
||||
{{ kimai_context.branding.company|raw }} – {{ now|date_full }}
|
||||
{% set company = config('theme.branding.company') %}
|
||||
{% if company is not empty %}
|
||||
{{ company|raw }} – {{ now|date_full }}
|
||||
{% else %}
|
||||
{{ 'export.date_copyright'|trans({'%date%': now|date_full, '%kimai%': '<a href="' ~ constant('App\\Constants::HOMEPAGE') ~ '">' ~ constant('App\\Constants::SOFTWARE') ~ '</a>'})|raw }}
|
||||
{% endif %}
|
||||
|
||||
@@ -138,7 +138,8 @@
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% block box_title %}
|
||||
<span id="invoice_preview_details_{{ customer.id }}">{{ widgets.label_customer(customer) }}</span>
|
||||
<small>{{ 'label.duration'|trans }}: {{ model.calculator.timeWorked|duration() }}, {{ 'label.total_rate'|trans }}: {{ model.calculator.total|money(currency) }}</small>
|
||||
{{ widgets.label(model.calculator.timeWorked|duration, 'gray') }}
|
||||
{{ widgets.label(model.calculator.total|money(currency), 'gray') }}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}invoice-preview-box no-padding{% endblock %}
|
||||
{% block box_body %}
|
||||
|
||||
224
templates/macros/charts.html.twig
Normal file
224
templates/macros/charts.html.twig
Normal file
@@ -0,0 +1,224 @@
|
||||
|
||||
{# ------------------------------------------------------------------------------ #}
|
||||
{# DOUNGHNUT CHART - @see https://www.chartjs.org/docs/2.9.4/charts/doughnut.html #}
|
||||
{# ------------------------------------------------------------------------------ #}
|
||||
|
||||
{#
|
||||
options = {'label': 'label', 'title': string|null, 'footer': string|null, 'legend': false}
|
||||
#}
|
||||
{% macro doughnut_javascript(options) %}
|
||||
<script type="text/javascript">
|
||||
function doughnutChartOptions() {
|
||||
return {
|
||||
legend: {% if options.legend is defined %}{{ options.legend }}{% else %}false{% endif %},
|
||||
maintainAspectRatio: true,
|
||||
responsive: true,
|
||||
cutoutPercentage: 40,
|
||||
animation: {
|
||||
animateRotate: true
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem, data) {
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return ' ' + tooltipData['{{ options.label|default('label') }}'];
|
||||
},
|
||||
afterTitle: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
{% if options.title is defined and options.title is not empty %}
|
||||
title: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return ' ' + tooltipData['{{ options.title }}'];
|
||||
},
|
||||
{% endif %}
|
||||
{% if options.footer is defined and options.footer is not empty %}
|
||||
beforeFooter: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
footer: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return tooltipData['{{ options.footer }}'];
|
||||
},
|
||||
{% endif %}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
id = 'name'
|
||||
labels = ['Foo', 'Bar']
|
||||
dataset = [{name: '', color: '', duration: 0, rate: 0}]
|
||||
options = {'height: '100px', 'renderEvent': 'kimai.initialized'}
|
||||
#}
|
||||
{% macro doughnut_chart(id, labels, dataset, options) %}
|
||||
{% set height = options.height|default(config('theme.chart.height') ~ 'px') %}
|
||||
{% set bgColor = config('theme.chart.background_color') %}
|
||||
<div class="chart" style="position: relative;">
|
||||
<canvas id="{{ id }}" style="height: {{ height }};"></canvas>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('{{ options.renderEvent|default('kimai.initialized') }}', function() {
|
||||
new Chart(
|
||||
$("#{{ id }}").get(0).getContext("2d"), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
{% if labels is not empty %}
|
||||
labels: {{ labels|json_encode|raw }},
|
||||
{% endif %}
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: [
|
||||
{% for entry in dataset %}
|
||||
'{{ entry.color|default(bgColor) }}',
|
||||
{% endfor %}
|
||||
],
|
||||
data: [
|
||||
{% for entry in dataset %}
|
||||
{{ entry.value }}
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
realData: {{ dataset|json_encode|raw }},
|
||||
}
|
||||
]
|
||||
},
|
||||
options: Object.assign(doughnutChartOptions(),{{ options|json_encode|raw }})
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
{# ------------------------------------------------------------------- #}
|
||||
{# BAR CHART - @see https://www.chartjs.org/docs/2.9.4/charts/bar.html #}
|
||||
{# ------------------------------------------------------------------- #}
|
||||
|
||||
{#
|
||||
options = {'label': 'label', 'title': string|null, 'footer': string|null, 'legend': false, 'onclick': {'url': string, 'replacer': {'name': 'value'}}}
|
||||
#}
|
||||
{% macro bar_javascript(options) %}
|
||||
<script type="text/javascript">
|
||||
function barChartOptions() {
|
||||
return {
|
||||
legend: {% if options.legend is defined %}{{ options.legend }}{% else %}false{% endif %},
|
||||
maintainAspectRatio: true,
|
||||
responsive: true,
|
||||
barPercentage: 0.5,
|
||||
categoryPercentage: 0.9,
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
},
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: '{{ config('theme.chart.grid_color') }}',
|
||||
lineWidth: 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem, data) {
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return ' ' + tooltipData['{{ options.label|default('label') }}'];
|
||||
},
|
||||
afterTitle: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
{% if options.title is defined and options.title is not empty %}
|
||||
title: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return ' ' + tooltipData['{{ options.title }}'];
|
||||
},
|
||||
{% endif %}
|
||||
{% if options.footer is defined and options.footer is not empty %}
|
||||
beforeFooter: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
footer: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return tooltipData['{{ options.footer }}'];
|
||||
},
|
||||
{% endif %}
|
||||
}
|
||||
},
|
||||
{% if options.onclick is defined and options.onclick.url is defined %}
|
||||
onClick: function(event, elements) {
|
||||
if (elements.length !== 1) {
|
||||
return;
|
||||
}
|
||||
var element = elements[0];
|
||||
var data = this.data.datasets[element._datasetIndex].realData[element._index];
|
||||
var url = '{{ options.onclick.url|raw }}';
|
||||
{% if options.onclick.replacer is defined %}
|
||||
{% for replacer, field in options.onclick.replacer %}
|
||||
url = url.replace(/{{ replacer }}/, data['{{ field }}']);
|
||||
{% endfor %}
|
||||
document.location = url;
|
||||
{% endif %}
|
||||
},
|
||||
{% endif %}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
id = 'name'
|
||||
labels = ['Foo', 'Bar']
|
||||
datasets = [[{'value': 1234, 'tooltip': '1234€', 'color': ''}]]
|
||||
options = {'height: '100px', 'renderEvent': 'kimai.initialized'}
|
||||
#}
|
||||
{% macro bar_chart(id, labels, datasets, options) %}
|
||||
{% set height = options.height|default(config('theme.chart.height') ~ 'px') %}
|
||||
{% set bgColor = config('theme.chart.background_color') %}
|
||||
<div class="chart" style="position: relative;">
|
||||
<canvas id="{{ id }}" style="height: {{ height }};"></canvas>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('{{ options.renderEvent|default('kimai.initialized') }}', function() {
|
||||
new Chart(
|
||||
document.getElementById('{{ id }}').getContext("2d"), {
|
||||
type: '{{ options.type|default('bar') }}',
|
||||
data: {
|
||||
{% if labels is not empty %}
|
||||
labels: {{ labels|json_encode|raw }},
|
||||
{% endif %}
|
||||
datasets: [
|
||||
{% for dataset in datasets %}
|
||||
{
|
||||
backgroundColor: [
|
||||
{% for entry in dataset %}
|
||||
'{{ entry.color|default(bgColor) }}',
|
||||
{% endfor %}
|
||||
],
|
||||
data: [
|
||||
{% for entry in dataset %}
|
||||
{{ entry.value }},
|
||||
{% endfor %}
|
||||
],
|
||||
realData: {{ dataset|json_encode|raw }},
|
||||
},
|
||||
{% endfor %}
|
||||
]
|
||||
},
|
||||
options: Object.assign(barChartOptions(),{{ options|json_encode|raw }})
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
@@ -63,49 +63,96 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_user(user) %}
|
||||
{{ _self.label(user.displayName, 'primary') }}
|
||||
{{ _self.label_color(user.displayName, user.color|colorize(user.displayName)) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_team(team, class) %}
|
||||
{{ _self.label(team.name, class|default('primary')) }}
|
||||
{% macro label_team(team) %}
|
||||
{{ _self.label_color(team.name, team.color|colorize(team.name)) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro user_avatar(user, tooltip, class) %}
|
||||
{% set avatar = avatar(user, admin_lte_context.default_avatar) %}
|
||||
{% if tooltip is same as (false) %}
|
||||
<img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" alt="{{ user.displayName }}" />
|
||||
{% if user.avatar is not empty and kimai_config.themeAllowAvatarUrls %}
|
||||
{% set avatar = asset(user.avatar, 'avatars') %}
|
||||
{% if tooltip is same as (false) %}
|
||||
<img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" alt="{{ user.displayName }}" />
|
||||
{% else %}
|
||||
<img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" data-toggle="tooltip" data-placement="top" alt="{{ user.displayName }}" title="{{ tooltip|default(user.displayName) }}" />
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" data-toggle="tooltip" data-placement="top" alt="{{ user.displayName }}" title="{{ tooltip|default(user.displayName) }}" />
|
||||
{% set color = user.color|colorize(user.displayName) %}
|
||||
<span class="avatar {{ class }}" style="background-color: {{ color }}; color: {{ color|font_contrast }}"
|
||||
{%- if tooltip is not same as (false) %} data-toggle="tooltip" data-placement="top" title="{{ tooltip|default(user.displayName) }}"{% endif -%}>
|
||||
<span class="initials">{{ user.initials }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_activity(activity, url) %}
|
||||
{#
|
||||
options = {'inherit': true, 'random': false}
|
||||
#}
|
||||
{% macro label_activity(activity, options) %}
|
||||
{% set inherit = options.inherit ?? true %}
|
||||
{% set random = options.random ?? false %}
|
||||
{% set isVisible = activity.visible %}
|
||||
{% set color = activity.color %}
|
||||
{% if color is empty and activity.project is not null %}
|
||||
{% if color is empty and inherit and activity.project is not null %}
|
||||
{% set color = activity.project.color ?? activity.project.customer.color %}
|
||||
{% endif %}
|
||||
{% if color is empty and random %}
|
||||
{% set color = color|colorize(activity.name) %}
|
||||
{% endif %}
|
||||
{% if isVisible and not activity.project is null %}
|
||||
{% set isVisible = activity.project.visible %}
|
||||
{% if isVisible and not activity.project.customer is null %}
|
||||
{% set isVisible = activity.project.customer.visible %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{{ _self.label_color_dot('activity', isVisible, activity.name, url, color) }}
|
||||
{{ _self.label_color_dot('activity', isVisible, activity.name, null, color) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_project(project, url) %}
|
||||
{#
|
||||
options = {'inherit': true, 'random': false}
|
||||
#}
|
||||
{% macro label_project(project, options) %}
|
||||
{% set inherit = options.inherit ?? true %}
|
||||
{% set random = options.random ?? false %}
|
||||
{% set isVisible = false %}
|
||||
{% if project.visible and project.customer.visible %}
|
||||
{% set isVisible = true %}
|
||||
{% endif %}
|
||||
{{ _self.label_color_dot('project', isVisible, project.name, url, (project.color ?? project.customer.color)) }}
|
||||
{% set color = project.color %}
|
||||
{% if color is empty and inherit %}
|
||||
{% set color = project.customer.color %}
|
||||
{% endif %}
|
||||
{% if color is empty and random %}
|
||||
{% set color = color|colorize(project.name) %}
|
||||
{% endif %}
|
||||
{{ _self.label_color_dot('project', isVisible, project.name, null, color) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_customer(customer, url) %}
|
||||
{{ _self.label_color_dot('customer', customer.visible, customer.name, url, customer.color) }}
|
||||
{#
|
||||
options = {'random': false}
|
||||
#}
|
||||
{% macro label_customer(customer, options) %}
|
||||
{% set random = options.random ?? false %}
|
||||
{% set color = customer.color %}
|
||||
{% if color is empty and random %}
|
||||
{% set color = color|colorize(customer.name) %}
|
||||
{% endif %}
|
||||
{{ _self.label_color_dot('customer', customer.visible, customer.name, null, color) }}
|
||||
{% endmacro %}
|
||||
|
||||
{# Use for list views and the main column (if the entity has something like a name) #}
|
||||
{% macro label_name(label, color, isVisible) %}
|
||||
{{ _self.label_color_dot('color', isVisible, label, null, color) }}
|
||||
{% endmacro %}
|
||||
|
||||
{# Use in datatables to show linked entity names #}
|
||||
{% macro label_dot(label, color, isVisible) %}
|
||||
{{ _self.label_color_dot('color', isVisible, label, null, color|colorize(label)) }}
|
||||
{% endmacro %}
|
||||
|
||||
{# for @internal use only #}
|
||||
{% macro label_color_dot(type, isVisible, name, url, color) %}
|
||||
<span class="label-{{ type }}{{ isVisible ? '' : ' label-invisible' }}">
|
||||
<span class="name">
|
||||
@@ -142,12 +189,15 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label(title, type, tooltip) %}
|
||||
{# success, warning, danger, primary #}
|
||||
<span {% if tooltip %}data-toggle="tooltip" data-placement="top" title="{{ tooltip }}" {% endif %}class="label label-{{ type|default('success') }}">{{ title }}</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro label_color(title, color) %}
|
||||
<span class="label" style="background-color: {{ color|default_color }};color: {{ color|default_color|font_contrast }}">{{ title }}</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro badge(title, color) %}
|
||||
<span class="badge" style="background-color:{{ color }}; color:{{ color|font_contrast }}">{{ title }}</span>
|
||||
<span class="badge"{% if color is not null %} style="background-color:{{ color }}; color:{{ color|font_contrast }}"{% endif %}>{{ title }}</span>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro alert(type, description, title, icon) %}
|
||||
@@ -393,25 +443,23 @@
|
||||
|
||||
{% macro button_action(icon, url) %}
|
||||
<a href="{{ url }}" class="btn btn-default btn-{{ icon }}">
|
||||
{{ macro.icon(icon) }}
|
||||
{{ _self.icon(icon) }}
|
||||
</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro tag_list(taglist) %}
|
||||
{% import _self as macro %}
|
||||
{% for tag in taglist %}
|
||||
{{ macro.badge(tag.name, tag.color|default('#00a65a')) }}
|
||||
{{ _self.badge(tag.name, tag.color|colorize(tag.name)) }}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro form_type_value(type, value, entity) %}
|
||||
{% import _self as widgets %}
|
||||
{% if '\\ColorPickerType' in type %}
|
||||
{{ widgets.color_dot(value, value) }}
|
||||
{{ _self.color_dot(value, value) }}
|
||||
{% elseif '\\DurationType' in type %}
|
||||
{{ value|duration }}
|
||||
{% elseif '\\YesNoType' in type or '\\CheckBoxType' in type %}
|
||||
{{ widgets.label_boolean(value) }}
|
||||
{{ _self.label_boolean(value) }}
|
||||
{% elseif '\\DatePickerType' in type %}
|
||||
{{ value|date_short }}
|
||||
{% elseif '\\DateTimePickerType' in type %}
|
||||
@@ -448,11 +496,10 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro team_list(teams, showTitle, collapseAt) %}
|
||||
{% set collapseAt = collapseAt ?? 5 %}
|
||||
{% set collapseAt = collapseAt ?? 12 %}
|
||||
{% if showTitle is null %}
|
||||
{% set showTitle = true %}
|
||||
{% endif %}
|
||||
{% import _self as macro %}
|
||||
<table class="table table-hover dataTable" role="grid">
|
||||
{% if showTitle %}
|
||||
<thead>
|
||||
@@ -471,15 +518,15 @@
|
||||
{{ team.name }}
|
||||
</td>
|
||||
<td class="avatars">
|
||||
{{ macro.user_avatar(team.teamlead, ('label.teamlead'|trans ~ ': ' ~ team.teamlead.displayName), 'teamlead') }}
|
||||
{{ _self.user_avatar(team.teamlead, ('label.teamlead'|trans ~ ': ' ~ team.teamlead.displayName), 'teamlead') }}
|
||||
{% set teamHiddenId = 'team_' ~ team.id ~ '_hiddenUser' ~ random() %}
|
||||
{% set counter = 0 %}
|
||||
{% for user in users %}
|
||||
{% if user != team.teamlead %}
|
||||
{{ macro.user_avatar(user) }}
|
||||
{{ _self.user_avatar(user) }}
|
||||
{% set counter = counter + 1 %}
|
||||
{% endif %}
|
||||
{% if userTeamCount > collapseAt and counter == (collapseAt - 1) and loop.index != userTeamCount %}
|
||||
{% if userTeamCount > (collapseAt + 1) and counter == (collapseAt - 1) and loop.index != userTeamCount %}
|
||||
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - collapseAt)}) }}</a>
|
||||
<span class="hidden" id="{{ teamHiddenId }}">
|
||||
{% set counter = counter + 1 %}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{% if not kimai_context.branding.logo is empty %}
|
||||
<img src="{{ kimai_context.branding.logo }}" style="max-width: 300px;">
|
||||
{% elseif not kimai_context.branding.company is empty %}
|
||||
<b>{{ kimai_context.branding.company|raw }}</b>
|
||||
{% set logo = config('theme.branding.logo') %}
|
||||
{% set company = config('theme.branding.company') %}
|
||||
{% if logo is not empty %}
|
||||
<img src="{{ logo }}" style="max-width: 300px;">
|
||||
{% elseif company is not empty %}
|
||||
<b>{{ company|raw }}</b>
|
||||
{% else %}
|
||||
{{- get_title('<b>', '</b><br>')|raw -}}
|
||||
{% endif %}
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
|
||||
{% for entry in entries %}
|
||||
<tr {{ widgets.project_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
|
||||
{{ widgets.label_name(entry.name, entry.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'orderNumber') }}">{{ entry.orderNumber }}</td>
|
||||
|
||||
488
templates/reporting/project_details.html.twig
Normal file
488
templates/reporting/project_details.html.twig
Normal file
@@ -0,0 +1,488 @@
|
||||
{% extends 'reporting/layout.html.twig' %}
|
||||
{% import "macros/charts.html.twig" as charts %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% block report_title %}{{ 'report_project_details'|trans({}, 'reporting') }}{% endblock %}
|
||||
|
||||
{% set tableName = tableName|default('project_details_reporting') %}
|
||||
{% set tableId = 'project-details-form' %}
|
||||
{% set view_revenue_tab = project_details is not null and is_granted('budget', project_details.project) %}
|
||||
{% set view_revenue_user = project_details is not null and is_granted('view_rate_other_timesheet') %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_link_tags('chart') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ parent() }}
|
||||
{{ encore_entry_script_tags('chart') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
{% set options = {'label': 'duration', 'title': 'name', 'legend': 'false'} %}
|
||||
{% if view_revenue_user and view_revenue_tab %}
|
||||
{% set options = options|merge({'footer': 'rate'}) %}
|
||||
{% endif %}
|
||||
{{ charts.doughnut_javascript(options) }}
|
||||
{{ charts.bar_javascript({'legend': 'false'}) }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
jQuery('#{{ tableId }}').on('change', function(ev) {
|
||||
jQuery(this).submit();
|
||||
});
|
||||
});
|
||||
|
||||
jQuery('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
|
||||
renderChart(e.target.dataset.chart);
|
||||
});
|
||||
|
||||
function renderChart(chartName)
|
||||
{
|
||||
var found = false;
|
||||
|
||||
Chart.helpers.each(Chart.instances, function(instance){
|
||||
if (instance.chart.canvas.id === chartName) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
document.dispatchEvent(new Event('render.' + chartName))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block report %}
|
||||
{% set hasData = project_view is not null %}
|
||||
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_before %}
|
||||
{{ form_start(form, {'attr': {'class': 'form-inline form-reporting', 'id': tableId}}) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ form_widget(form) }}
|
||||
{% endblock %}
|
||||
{% block box_body %}{% endblock %}
|
||||
{% block box_body_class %}hidden{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% if not hasData %}
|
||||
{{ widgets.nothing_found() }}
|
||||
{% endif %}
|
||||
|
||||
{% if hasData %}
|
||||
{{ _self.project_details(project_view, project_details, view_revenue_tab, view_revenue_user) }}
|
||||
{% set currency = project_view.project.customer.currency %}
|
||||
|
||||
{%- for yearStat in project_details.years|reverse %}
|
||||
{% set year = yearStat.year %}
|
||||
{{ _self.duration_stat(year, year, year, month_names(), yearStat, project_details.getYearActivities(year), project_details.userYears(year), currency, view_revenue_tab) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% macro duration_stat(id, title, year, labels, yearStat, activities, users, currency, view_revenue_tab) %}
|
||||
{% set rates = [] %}
|
||||
{% set durations = [] %}
|
||||
{% set chartPrefix = 'chart' ~ id %}
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set rate = 0 %}
|
||||
{% set duration = 0 %}
|
||||
{% set month = yearStat.month(monthNumber) %}
|
||||
{% if month is not null %}
|
||||
{% set rate = month.totalRate %}
|
||||
{% set duration = month.totalDuration %}
|
||||
{% endif %}
|
||||
{% set durations = durations|merge([{'label': duration|duration, 'value': duration|chart_duration}]) %}
|
||||
{% set rates = rates|merge([{'label': rate|money(currency), 'value': rate}]) %}
|
||||
{% endfor -%}
|
||||
|
||||
<div class="nav-tabs-custom">
|
||||
<ul class="nav nav-tabs pull-right">
|
||||
<li><a data-chart="{{ chartPrefix }}User" href="#user-chart-{{ id}}" data-toggle="tab">{{ 'label.user'|trans }}</a></li>
|
||||
<li><a data-chart="{{ chartPrefix }}Activity" href="#activity-chart-{{ id}}" data-toggle="tab">{{ 'label.activity'|trans }}</a></li>
|
||||
{% if view_revenue_tab %}
|
||||
<li><a data-chart="{{ chartPrefix }}Rate" href="#revenue-chart-{{ id }}" data-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
|
||||
{% endif %}
|
||||
<li class="active"><a data-chart="{{ chartPrefix }}Duration" href="#time-chart-{{ id }}" data-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
|
||||
<li class="pull-left header">{{ title }} {{ widgets.label(yearStat.duration|duration, 'gray') }} {{ widgets.label(yearStat.rate|money(currency), 'gray') }}</small></li>
|
||||
</ul>
|
||||
<div class="tab-content no-padding">
|
||||
<div class="chart tab-pane active" id="time-chart-{{ id }}">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{{ charts.bar_chart(chartPrefix ~ 'Duration', labels, [durations], {'height': '300px'}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if view_revenue_tab %}
|
||||
<div class="chart tab-pane" id="revenue-chart-{{ id }}">
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{{ charts.bar_chart(chartPrefix ~ 'Rate', labels, [rates], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix ~ 'Rate'}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="chart tab-pane" id="activity-chart-{{ id }}">
|
||||
{{ _self.activity_tab(activities, yearStat.duration, currency, chartPrefix, view_revenue_tab) }}
|
||||
</div>
|
||||
<div class="chart tab-pane" id="user-chart-{{ id }}">
|
||||
{{ _self.user_tab(year, users) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro user_tab(year, userYearStats) %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 table-responsive">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr>
|
||||
<th>{{ 'label.username'|trans }}</th>
|
||||
<th></th>
|
||||
{% for monthName in month_names() %}
|
||||
<th class="text-center">{{ monthName }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% set yearTotal = 0 %}
|
||||
{% for userYearStat in userYearStats|sort((a, b) => a.duration <= b.duration) %}
|
||||
{% set user = userYearStat.user %}
|
||||
{% set userYear = userYearStat.year %}
|
||||
{% set userTotal = userYearStat.duration %}
|
||||
{% set yearTotal = yearTotal + userTotal %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
{{ widgets.label_dot(user.displayName, user.color) }}
|
||||
</td>
|
||||
<th class="text-nowrap text-center total">
|
||||
{{ userTotal|duration }}
|
||||
</th>
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set month = userYear.getMonth(monthNumber) %}
|
||||
<td class="text-nowrap text-center">
|
||||
{% if month is not null %}
|
||||
{{ month.totalDuration|duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-nowrap text-center total">
|
||||
{{ yearTotal|duration }}
|
||||
</th>
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set total = 0 %}
|
||||
{% for userYearStat in userYearStats %}
|
||||
{% set month = userYearStat.year.getMonth(monthNumber) %}
|
||||
{% if month is not null %}
|
||||
{% set total = total + month.duration %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<th class="text-nowrap text-center total">
|
||||
{{ total|duration }}
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
activities = array<ActivityStatistic>
|
||||
totalDuration = int
|
||||
currency = string
|
||||
chartPrefix = string
|
||||
view_revenue_tab = boolean
|
||||
#}
|
||||
{% macro activity_tab(activities, totalDuration, currency, chartPrefix, view_revenue_tab) %}
|
||||
{% set dataset = [] %}
|
||||
{% set labels = [] %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
|
||||
<table class="table table-hover dataTable">
|
||||
{% for stat in activities|sort((a, b) => a.duration <= b.duration) %}
|
||||
{% set dataset = dataset|merge([{'value': stat.duration, 'name': stat.name, 'color': stat.activity.color|colorize(stat.activity.name), 'duration': stat.duration|duration, 'rate': stat.rate|money(currency)}]) %}
|
||||
{% set percentage = 0 %}
|
||||
{% if totalDuration > 0 and stat.duration > 0 %}
|
||||
{% set percentage = (100 / (totalDuration / stat.duration)) %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>{{ widgets.label_activity(stat.activity, {'inherit': false, 'random': true}) }}</td>
|
||||
<td class="text-nowrap text-right">{{ stat.duration|duration }}</td>
|
||||
{% if view_revenue_tab %}
|
||||
<td class="text-nowrap text-right">{{ stat.rate|money(currency) }}</td>
|
||||
{% endif %}
|
||||
<td class="text-nowrap text-right">{{ percentage|number_format(1) }} %</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">
|
||||
{% set chartOptions = {'height': (dataset|length > 12 ? '600px' : '300px'), 'legend': {'position': (dataset|length > 12 ? 'top' : 'right')}, 'renderEvent': 'render.' ~ chartPrefix ~ 'Activity'} %}
|
||||
{{ charts.doughnut_chart(chartPrefix ~ 'Activity', labels, dataset, chartOptions) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
entry = ProjectViewModel
|
||||
project_details = ProjectDetailsModel
|
||||
#}
|
||||
{% macro project_details(entry, project_details, view_revenue_tab, view_revenue_user) %}
|
||||
{% set activities = project_details.activities %}
|
||||
{% set years = project_details.years %}
|
||||
{% import "macros/progressbar.html.twig" as progress %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/charts.html.twig" as charts %}
|
||||
{% set project = entry.project %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% set chartPrefix = 'project' ~ project.id %}
|
||||
|
||||
{%- set labels = [] %}
|
||||
{% set rates = [] %}
|
||||
{% set durations = [] %}
|
||||
{% for year in years %}
|
||||
{% set labels = labels|merge([year.year]) %}
|
||||
{% set rates = rates|merge([{'label': year.rate|money(currency), 'value': year.rate}]) %}
|
||||
{% set durations = durations|merge([{'label': year.duration|duration, 'value': year.duration|chart_duration}]) %}
|
||||
{% endfor -%}
|
||||
|
||||
<div class="nav-tabs-custom">
|
||||
<ul class="nav nav-tabs pull-right">
|
||||
|
||||
<li><a data-chart="{{ chartPrefix }}User" href="#user-chart" data-toggle="tab">{{ 'label.user'|trans }}</a></li>
|
||||
<li><a data-chart="{{ chartPrefix }}Activity" href="#activity-chart" data-toggle="tab">{{ 'label.activity'|trans }}</a></li>
|
||||
{% if view_revenue_tab %}
|
||||
<li><a data-chart="{{ chartPrefix }}Rate" href="#revenue-chart" data-toggle="tab">{{ 'stats.revenue'|trans }}</a></li>
|
||||
{% endif %}
|
||||
<li><a data-chart="{{ chartPrefix }}Duration" href="#time-chart" data-toggle="tab">{{ 'stats.workingTime'|trans }}</a></li>
|
||||
<li class="active"><a href="#details-chart" data-toggle="tab">{{ 'report_project_details'|trans({}, 'reporting') }}</a></li>
|
||||
<li class="pull-left header">
|
||||
{{ widgets.label_project(project, {'inherit': false}) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content no-padding">
|
||||
<div class="chart tab-pane active" id="details-chart">
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr {{ widgets.customer_row_attr(entry.project.customer) }}>
|
||||
<th class="w-min">
|
||||
{{ 'label.customer'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ widgets.label_customer(entry.project.customer) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationToday'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationDay|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationWeek'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationWeek|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationMonth'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationMonth|duration }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'stats.durationTotal'|trans }}
|
||||
</th>
|
||||
<td>{{ entry.durationTotal|duration }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.last_record'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{% if entry.lastRecord is not null %}
|
||||
{{ entry.lastRecord|date_short }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.not_exported'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{% if is_granted('create_export') %}
|
||||
<a href="{{ path('export', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': '', 'preview': true}) }}">
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.not_invoiced'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{% if is_granted('view_invoice') %}
|
||||
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if project.start is not null %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.project_start'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ project.start|date_short }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if project.end is not null %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.project_end'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{{ project.end|date_short }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% if is_granted('budget', project) and (project.timeBudget > 0 or project.budget > 0) %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<table class="table table-hover dataTable">
|
||||
{% if project.timeBudget > 0 %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.timeBudget'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{% set timeBudgetLeft = project.timeBudget - entry.billableDuration %}
|
||||
{% set options = {
|
||||
'max': project.timeBudget|default(0),
|
||||
'current': entry.billableDuration|default(0),
|
||||
'open': timeBudgetLeft,
|
||||
'format': 'duration'
|
||||
} %}
|
||||
{{ progress.progressbar_full(options) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if project.budget > 0 %}
|
||||
<tr>
|
||||
<th class="w-min">
|
||||
{{ 'label.budget'|trans }}
|
||||
</th>
|
||||
<td>
|
||||
{% set budgetLeft = project.budget - entry.billableRate %}
|
||||
{% set options = {
|
||||
'max': project.budget|default(0),
|
||||
'current': entry.billableRate|default(0),
|
||||
'open': budgetLeft,
|
||||
'format': 'money',
|
||||
'currency': currency
|
||||
} %}
|
||||
{{ progress.progressbar_full(options) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% if view_revenue_tab %}
|
||||
<div class="chart tab-pane" id="revenue-chart">
|
||||
{{ charts.bar_chart(chartPrefix ~ 'Rate', labels, [rates], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix ~ 'Rate'}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="chart tab-pane" id="time-chart">
|
||||
{{ charts.bar_chart(chartPrefix ~ 'Duration', labels, [durations], {'height': '300px', 'renderEvent': 'render.' ~ chartPrefix ~ 'Duration'}) }}
|
||||
</div>
|
||||
<div class="chart tab-pane" id="activity-chart">
|
||||
{{ _self.activity_tab(activities, entry.durationTotal, project.customer.currency, chartPrefix, view_revenue_tab) }}
|
||||
</div>
|
||||
<div class="chart tab-pane" id="user-chart">
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
|
||||
<table class="table table-hover dataTable">
|
||||
{% set rateTotal = 0 %}
|
||||
{% set totalDuration = 0 %}
|
||||
{% set datasets = [] %}
|
||||
{% set labels = [] %}
|
||||
{% for userStat in project_details.userStats|sort((a, b) => a.duration <= b.duration) %}
|
||||
{% set user = userStat.user %}
|
||||
{% set color = user.color|colorize(user.displayName) %}
|
||||
{% set rateTotal = rateTotal + userStat.rate %}
|
||||
{% set totalDuration = totalDuration + userStat.duration %}
|
||||
{% set datasets = datasets|merge([{'name': user.displayName, 'duration': userStat.duration|duration, 'value': userStat.duration, 'color': color, 'rate': userStat.rate|money(currency)}]) %}
|
||||
{% set percentage = 0 %}
|
||||
{% if entry.durationTotal > 0 and userStat.duration > 0 %}
|
||||
{% set percentage = (100 / (entry.durationTotal / userStat.duration)) %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ widgets.label_dot(user.displayName, user.color) }}
|
||||
</th>
|
||||
<td class="text-nowrap text-right">
|
||||
{{ userStat.duration|duration }}
|
||||
</td>
|
||||
{% if view_revenue_user %}
|
||||
<td class="text-nowrap text-right">
|
||||
{{ userStat.rate|money(currency) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-nowrap text-right">
|
||||
{{ percentage|number_format(1) }} %
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<th class="text-nowrap text-right total">{{ totalDuration|duration }}</th>
|
||||
{% if view_revenue_user %}
|
||||
<th class="text-nowrap text-right total">{{ rateTotal|money(currency) }}</th>
|
||||
{% endif %}
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">
|
||||
{% set chartOptions = {'height': (datasets|length > 12 ? '600px' : '300px'), 'legend': {'position': (datasets|length > 12 ? 'top' : 'right')}, 'renderEvent': 'render.' ~ chartPrefix ~ 'User'} %}
|
||||
{{ charts.doughnut_chart(chartPrefix ~ 'User', labels, datasets, chartOptions) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
@@ -61,7 +61,7 @@
|
||||
{% set project = entry.project %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% if is_granted('budget', project) %}
|
||||
<tr {{ widgets.project_row_attr(project, now) }}>
|
||||
<tr{% if is_granted('details', project) %} class="alternative-link open-edit" data-href="{{ path('report_project_details', {'project': project.id}) }}"{% endif %}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_project(project) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'lastRecord') }}">
|
||||
{% if entry.lastRecord is not null %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
{% set usersTotalDuration = 0 %}
|
||||
<tr class="user">
|
||||
<td class="text-nowrap">
|
||||
<strong>{{ widgets.username(userDay.user) }}</strong>
|
||||
{{ widgets.label_dot(userDay.user.displayName, userDay.user.color) }}
|
||||
</td>
|
||||
{% for day in userDay.days %}
|
||||
{% if day.totalDuration > 0 %}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{% set usersTotalDuration = 0 %}
|
||||
<tr class="user">
|
||||
<td class="text-nowrap">
|
||||
<strong>{{ widgets.username(userYear.user) }}</strong>
|
||||
{{ widgets.label_dot(userYear.user.displayName, userYear.user.color) }}
|
||||
</td>
|
||||
{% for yid, year in userYear.years %}
|
||||
{% for mid, month in year.months %}
|
||||
|
||||
@@ -6,9 +6,22 @@
|
||||
{% block page_actions %}{{ actions.tag(tag, 'edit') }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
|
||||
{% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
|
||||
{% set formOptions = {
|
||||
'title': tag.name|default('create'|trans),
|
||||
'form': form,
|
||||
'back': path('tags')
|
||||
}) }}
|
||||
} %}
|
||||
{% embed formEditTemplate with formOptions %}
|
||||
{% block form_body %}
|
||||
<div class="row">
|
||||
<div class="col-md-10">
|
||||
{{ form_row(form.name) }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ form_row(form.color) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
|
||||
{{ widgets.label_color_dot('tag', true, tag.name, null, tag.color|default('#00a65a')) }}
|
||||
{{ widgets.label_name(tag.name, tag.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}">{{ widgets.label(tag.amount, type) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
|
||||
@@ -26,8 +26,12 @@
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.teamUpdate'}) }}
|
||||
{% 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 %}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ team.name }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'teamlead') }}">{{ widgets.label_user(team.teamlead) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">
|
||||
{{ widgets.label_name(team.name, team.color) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'teamlead') }}">
|
||||
{{ widgets.label_user(team.teamlead) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
|
||||
{% for user in team.users %}
|
||||
{{ widgets.label_user(user) }}
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
{% block profile_content %}
|
||||
|
||||
{{ form_start(form) }}
|
||||
{% for section in sections %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% block box_body %}
|
||||
{% for pref in form.children.preferences|filter(pref => pref.vars.data.section == section)|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(pref) }}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endfor %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
{{ form_end(form) }}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ ('profile.' ~ tab)|trans }}{% endblock %}
|
||||
{% block box_body %}
|
||||
{{ form_start(form) }}
|
||||
{{ form_widget(form) }}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
18
templates/user/preferences.html.twig
Normal file
18
templates/user/preferences.html.twig
Normal file
@@ -0,0 +1,18 @@
|
||||
{% extends 'user/layout.html.twig' %}
|
||||
|
||||
{% block profile_content %}
|
||||
|
||||
{{ form_start(form) }}
|
||||
{% for section in sections %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% block box_body %}
|
||||
{% for pref in form.children.preferences|filter(pref => pref.vars.data.section == section)|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(pref) }}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
{% endfor %}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% endblock %}
|
||||
@@ -7,6 +7,14 @@
|
||||
{% block box_title %}{{ ('profile.' ~ tab)|trans }}{% endblock %}
|
||||
{% block box_body %}
|
||||
{{ form_start(form) }}
|
||||
<div class="row">
|
||||
<div class="col-md-10">
|
||||
{{ form_row(form.alias) }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{{ form_row(form.color) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ form_widget(form) }}
|
||||
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
|
||||
{{ form_end(form) }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'user/layout.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/charts.html.twig" as charts %}
|
||||
|
||||
{% block stylesheets %}
|
||||
{{ parent() }}
|
||||
@@ -25,127 +26,26 @@
|
||||
{{ widgets.nothing_found() }}
|
||||
{% endif %}
|
||||
|
||||
<script type="text/javascript">
|
||||
function userProfileChartOptions() {
|
||||
return {
|
||||
maintainAspectRatio: true,
|
||||
responsive: true,
|
||||
legend: false,
|
||||
barPercentage: 0.5,
|
||||
categoryPercentage: 0.9,
|
||||
scales: {
|
||||
xAxes: [{
|
||||
gridLines: {
|
||||
display: false
|
||||
}
|
||||
}],
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: true
|
||||
},
|
||||
gridLines: {
|
||||
display: true,
|
||||
color: '{{ kimai_context.chart.grid_color }}',
|
||||
lineWidth: 1
|
||||
}
|
||||
}]
|
||||
},
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem, data) {
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return tooltipData['duration'];
|
||||
},
|
||||
afterTitle: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
beforeFooter: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
footer: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return '{{ 'label.billable'|trans }}: ' + tooltipData['billable'];
|
||||
},
|
||||
}
|
||||
}
|
||||
{%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
|
||||
,
|
||||
onClick: function(event, elements) {
|
||||
var element = elements[0];
|
||||
var month = this.data.datasets[0].monthData[element._index];
|
||||
var formattedMonth = moment(month).format('{{ stat_date_format }}');
|
||||
document.location = '{{ path('report_user_month', {'user': user.id, 'date': 'XXXXX'})|raw }}'.replace('XXXXX', formattedMonth);
|
||||
}
|
||||
{% endif %}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% set monthRoute = null %}
|
||||
{%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
|
||||
{% set monthRoute = path('report_user_month', {'user': user.id, 'date': '__MONTH__'}) %}
|
||||
{% endif %}
|
||||
|
||||
{% for year, yearStat in years %}
|
||||
{{ charts.bar_javascript({'footer': 'footer', 'label': 'tooltip', 'onclick': {'url': monthRoute, 'replacer': {'__MONTH__': 'month'}}}) }}
|
||||
|
||||
{% for yearStat in years %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% block box_title %}{{ year }}{% endblock %}
|
||||
{% import "macros/charts.html.twig" as charts %}
|
||||
{% block box_title %}{{ yearStat.year }}{% endblock %}
|
||||
{% block box_body %}
|
||||
<div class="chart">
|
||||
<canvas id="userProfileChart{{ year }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
var userProfileChartData{{ year }} = {
|
||||
labels: moment.months(),
|
||||
datasets: [
|
||||
{
|
||||
label: '{{ year }}',
|
||||
backgroundColor: '{{ kimai_context.chart.background_color }}',
|
||||
borderColor: '{{ kimai_context.chart.border_color }}',
|
||||
data: [
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set duration = 0 %}
|
||||
{% set month = yearStat.month(monthNumber) %}
|
||||
{% if month is not null %}
|
||||
{% set duration = month.totalDuration %}
|
||||
{% endif %}
|
||||
{{ (duration / 3600)|number_format(2, '.', '') }}
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
realData: [
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set duration = 0 %}
|
||||
{% set billable = 0 %}
|
||||
{% set month = yearStat.month(monthNumber) %}
|
||||
{% if month is not null %}
|
||||
{% set duration = month.totalDuration %}
|
||||
{% set billable = month.billableDuration %}
|
||||
{% endif %}
|
||||
{% set realDayData = {'duration': duration|duration, 'billable': billable|duration} %}
|
||||
{{ realDayData|json_encode|raw }}
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
monthData: [
|
||||
{% for monthNumber in 1..12 %}
|
||||
'{{ yearStat.year }}-{{ monthNumber < 10 ? '0' ~ monthNumber : monthNumber }}-01'
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var userProfileChart{{ year }} = new Chart(
|
||||
$("#userProfileChart{{ year }}").get(0).getContext("2d"), {
|
||||
type: 'bar',
|
||||
data: userProfileChartData{{ year }},
|
||||
options: userProfileChartOptions()
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
{% set dataset = [] %}
|
||||
{% for month in yearStat.months %}
|
||||
{% set monthDate = create_date(yearStat.year ~ '-' ~ month.month ~ '-01') %}
|
||||
{% set dataset = dataset|merge([{'value': month.duration|chart_duration, 'tooltip': month.duration|duration, 'footer': ('label.billable'|trans ~ ': ' ~ month.billableDuration|duration), 'month': monthDate|date_short}]) %}
|
||||
{% endfor %}
|
||||
{{ charts.bar_chart('userProfileChart'~yearStat.year, month_names(), [dataset], {}) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% endfor %}
|
||||
|
||||
{% if user.teams is not empty and (is_granted('teams', user) or (app.user == user and is_granted('view_team_member'))) %}
|
||||
@@ -244,7 +144,7 @@
|
||||
{% import "@AdminLTE/Macros/default.html.twig" as macro %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% set color = kimai_context.chart.background_color %}
|
||||
{% set color = config('theme.chart.background_color') %}
|
||||
{% set event = trigger('user.profile_background', {'color': color, 'font': color|font_contrast, 'user': user, 'image': null}) %}
|
||||
{% set config = event.payload %}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{% set type = options.type|default('bar') %}
|
||||
{% set chart_id = options.id %}
|
||||
{% set backgroundColor = kimai_context.chart.background_color %}
|
||||
{% set gridColor = kimai_context.chart.grid_color %}
|
||||
{% set backgroundColor = config('theme.chart.background_color') %}
|
||||
{% set gridColor = config('theme.chart.grid_color') %}
|
||||
{% set activities = data.activities %}
|
||||
{% set data = data.data %}
|
||||
|
||||
<div class="chart">
|
||||
<canvas id="{{ chart_id }}" style="height: {{ kimai_context.chart.height }}px;"></canvas>
|
||||
<canvas id="{{ chart_id }}" style="height: {{ config('theme.chart.height') }}px;"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -38,7 +38,7 @@
|
||||
{%- for entry in day.details -%}
|
||||
{% set loopId = (entry.project.id ~ '_' ~ entry.activity.id) %}
|
||||
{%- if loopId == activityId -%}
|
||||
{% set realDayData = (entry.duration / 3600)|number_format(2, '.', '') %}
|
||||
{% set realDayData = entry.duration|chart_duration %}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if realDayData is not null -%}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% set backgroundColor = kimai_context.chart.background_color %}
|
||||
{% set borderColor = kimai_context.chart.border_color %}
|
||||
{% set gridColor = kimai_context.chart.grid_color %}
|
||||
{% set backgroundColor = config('theme.chart.background_color') %}
|
||||
{% set borderColor = config('theme.chart.border_color') %}
|
||||
{% set gridColor = config('theme.chart.grid_color') %}
|
||||
{% set colors = options.color|default('')|split(';') %}
|
||||
{% set type = options.type|default('line') %}
|
||||
{% if type not in ['line', 'bar'] %}
|
||||
@@ -16,7 +16,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="chart">
|
||||
<canvas id="timeChart" style="height: {{ kimai_context.chart.height }}px;"></canvas>
|
||||
<canvas id="timeChart" style="height: {{ config('theme.chart.height') }}px;"></canvas>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -52,7 +52,7 @@ document.addEventListener('kimai.initialized', function() {
|
||||
lineTension : 0.3,
|
||||
data : [
|
||||
{% for month in year.months %}
|
||||
{{ (month.totalDuration / 3600)|number_format(2, '.', '') }}
|
||||
{{ month.totalDuration|chart_duration }}
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
|
||||
@@ -269,7 +269,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
return [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'color' => 'string',
|
||||
'color' => '@string',
|
||||
];
|
||||
|
||||
// embedded meta data
|
||||
@@ -290,6 +290,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'id' => 'int',
|
||||
'username' => 'string',
|
||||
'enabled' => 'bool',
|
||||
'color' => '@string',
|
||||
'alias' => '@string',
|
||||
];
|
||||
|
||||
@@ -302,6 +303,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'alias' => '@string',
|
||||
'title' => '@string',
|
||||
'avatar' => '@string',
|
||||
'color' => '@string',
|
||||
'teams' => ['result' => 'array', 'type' => 'Team'],
|
||||
'roles' => ['result' => 'array', 'type' => 'string'],
|
||||
'language' => 'string',
|
||||
@@ -315,6 +317,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
return [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'color' => '@string',
|
||||
];
|
||||
|
||||
// explicitly requested team
|
||||
@@ -322,6 +325,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
return [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'color' => '@string',
|
||||
'teamlead' => ['result' => 'object', 'type' => 'User'],
|
||||
'users' => ['result' => 'array', 'type' => 'User'],
|
||||
'customers' => ['result' => 'array', 'type' => '@Customer'],
|
||||
|
||||
@@ -70,7 +70,7 @@ class TagControllerTest extends APIControllerBaseTest
|
||||
$this->importTagFixtures();
|
||||
$data = [
|
||||
'name' => 'foo',
|
||||
'color' => '#000FFF'
|
||||
'color' => '#00ff00'
|
||||
];
|
||||
$this->request($client, '/api/tags', 'POST', [], json_encode($data));
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
@@ -79,7 +79,7 @@ class TagControllerTest extends APIControllerBaseTest
|
||||
$this->assertIsArray($result);
|
||||
self::assertApiResponseTypeStructure('TagEntity', $result);
|
||||
$this->assertNotEmpty($result['id']);
|
||||
self::assertEquals('#000FFF', $result['color']);
|
||||
self::assertEquals('#00ff00', $result['color']);
|
||||
}
|
||||
|
||||
public function testPostActionWithValidationErrors()
|
||||
|
||||
@@ -653,13 +653,13 @@ class TeamControllerTest extends APIControllerBaseTest
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
// team not found
|
||||
$this->request($client, '/api/teams/999/activities/999', 'DELETE');
|
||||
$this->request($client, '/api/teams/999/activities/9999', 'DELETE');
|
||||
self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode());
|
||||
$json = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertEquals('Team not found', $json['message']);
|
||||
|
||||
// activity not found
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/activities/999', 'DELETE');
|
||||
$this->request($client, '/api/teams/' . $result['id'] . '/activities/9999', 'DELETE');
|
||||
self::assertEquals(Response::HTTP_NOT_FOUND, $client->getResponse()->getStatusCode());
|
||||
$json = json_decode($client->getResponse()->getContent(), true);
|
||||
self::assertEquals('Activity not found', $json['message']);
|
||||
|
||||
@@ -134,7 +134,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
$data = [
|
||||
'username' => 'foo',
|
||||
'email' => 'foo@example.com',
|
||||
'avatar' => 'test123',
|
||||
'title' => 'asdfghjkl',
|
||||
'plainPassword' => 'foo@example.com',
|
||||
'enabled' => true,
|
||||
@@ -153,7 +152,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
self::assertApiResponseTypeStructure('UserEntity', $result);
|
||||
$this->assertNotEmpty($result['id']);
|
||||
self::assertEquals('foo', $result['username']);
|
||||
self::assertEquals('test123', $result['avatar']);
|
||||
self::assertEquals('asdfghjkl', $result['title']);
|
||||
self::assertTrue($result['enabled']);
|
||||
self::assertEquals('ru', $result['language']);
|
||||
@@ -167,7 +165,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
$data = [
|
||||
'username' => 'foo',
|
||||
'email' => 'foo@example.com',
|
||||
'avatar' => 'test123',
|
||||
'title' => 'asdfghjkl',
|
||||
'plainPassword' => '1234567',
|
||||
'enabled' => true,
|
||||
@@ -230,7 +227,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
$data = [
|
||||
'username' => 'foo',
|
||||
'email' => 'foo@example.com',
|
||||
'avatar' => 'test123',
|
||||
'title' => 'asdfghjkl',
|
||||
'plainPassword' => 'foo@example.com',
|
||||
'enabled' => true,
|
||||
@@ -246,7 +242,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
$result = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$data = [
|
||||
'avatar' => 'test321',
|
||||
'title' => 'qwertzui',
|
||||
'enabled' => false,
|
||||
'language' => 'it',
|
||||
@@ -263,7 +258,6 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
self::assertApiResponseTypeStructure('UserEntity', $result);
|
||||
$this->assertNotEmpty($result['id']);
|
||||
self::assertEquals('foo', $result['username']);
|
||||
self::assertEquals('test321', $result['avatar']);
|
||||
self::assertEquals('qwertzui', $result['title']);
|
||||
self::assertFalse($result['enabled']);
|
||||
self::assertEquals('it', $result['language']);
|
||||
@@ -279,7 +273,7 @@ class UserControllerTest extends APIControllerBaseTest
|
||||
public function testPatchActionWithInvalidUser()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
$this->request($client, '/api/users/1', 'PATCH', [], json_encode(['avatar' => 'asdasd']));
|
||||
$this->request($client, '/api/users/1', 'PATCH', [], json_encode(['language' => 'hu']));
|
||||
$this->assertApiResponseAccessDenied($client->getResponse(), 'Not allowed to edit user');
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,6 @@ class SystemConfigurationTest extends TestCase
|
||||
$this->assertTrue($sut->find('theme.colors_limited'));
|
||||
$this->assertTrue($sut->isThemeColorsLimited());
|
||||
$this->assertEquals('Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,#ffffff,,|#000000', $sut->getThemeColorChoices());
|
||||
$this->assertEquals('Acme Corp.', $sut->getBrandingCompany());
|
||||
$this->assertEquals('Fantastic Time-Tracking', $sut->getBrandingTitle());
|
||||
$this->assertTrue($sut->isAllowTagCreation());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$content = $client->getResponse()->getContent();
|
||||
$year = (new \DateTime())->format('Y');
|
||||
$this->assertStringContainsString('<h3 class="box-title">' . $year . '</h3>', $content);
|
||||
$this->assertStringContainsString('var userProfileChart' . $year . ' = new Chart(', $content);
|
||||
$this->assertStringContainsString('new Chart(', $content);
|
||||
$this->assertStringContainsString('<canvas id="userProfileChart' . $year . '"', $content);
|
||||
}
|
||||
|
||||
public function testIndexAction()
|
||||
@@ -69,7 +70,7 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
foreach ($dates as $start) {
|
||||
$year = $start->format('Y');
|
||||
$this->assertStringContainsString('<h3 class="box-title">' . $year . '</h3>', $content);
|
||||
$this->assertStringContainsString('var userProfileChart' . $year . ' = new Chart(', $content);
|
||||
$this->assertStringContainsString('<canvas id="userProfileChart' . $year . '"', $content);
|
||||
}
|
||||
|
||||
$this->assertHasProfileBox($client, 'John Doe');
|
||||
@@ -80,9 +81,9 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
{
|
||||
$profileBox = $client->getCrawler()->filter('div.box-user-profile');
|
||||
$this->assertEquals(1, $profileBox->count());
|
||||
$profileAvatar = $profileBox->filter('img.img-circle');
|
||||
$profileAvatar = $profileBox->filter('span.avatar');
|
||||
$this->assertEquals(1, $profileAvatar->count());
|
||||
$alt = $profileAvatar->attr('alt');
|
||||
$alt = $profileAvatar->attr('title');
|
||||
|
||||
$this->assertEquals($username, $alt);
|
||||
}
|
||||
@@ -131,7 +132,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
$this->assertEquals('John Doe', $user->getAlias());
|
||||
$this->assertEquals('Developer', $user->getTitle());
|
||||
$this->assertEquals(UserFixtures::DEFAULT_AVATAR, $user->getAvatar());
|
||||
$this->assertEquals('john_user@example.com', $user->getEmail());
|
||||
$this->assertTrue($user->isEnabled());
|
||||
|
||||
@@ -140,7 +140,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
'user_edit' => [
|
||||
'alias' => 'Johnny',
|
||||
'title' => 'Code Monkey',
|
||||
'avatar' => '/fake/image.jpg',
|
||||
'email' => 'updated@example.com',
|
||||
]
|
||||
]);
|
||||
@@ -157,7 +156,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
$this->assertEquals('Johnny', $user->getAlias());
|
||||
$this->assertEquals('Code Monkey', $user->getTitle());
|
||||
$this->assertEquals('/fake/image.jpg', $user->getAvatar());
|
||||
$this->assertEquals('updated@example.com', $user->getEmail());
|
||||
$this->assertTrue($user->isEnabled());
|
||||
}
|
||||
@@ -172,7 +170,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
'user_edit' => [
|
||||
'alias' => 'Johnny',
|
||||
'title' => 'Code Monkey',
|
||||
'avatar' => '/fake/image.jpg',
|
||||
'email' => 'updated@example.com',
|
||||
'enabled' => false,
|
||||
]
|
||||
@@ -190,7 +187,6 @@ class ProfileControllerTest extends ControllerBaseTest
|
||||
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
|
||||
$this->assertEquals('Johnny', $user->getAlias());
|
||||
$this->assertEquals('Code Monkey', $user->getTitle());
|
||||
$this->assertEquals('/fake/image.jpg', $user->getAvatar());
|
||||
$this->assertEquals('updated@example.com', $user->getEmail());
|
||||
$this->assertFalse($user->isEnabled());
|
||||
}
|
||||
|
||||
64
tests/Controller/Reporting/ProjectDetailsControllerTest.php
Normal file
64
tests/Controller/Reporting/ProjectDetailsControllerTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?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\Reporting;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Tests\Controller\ControllerBaseTest;
|
||||
use App\Tests\DataFixtures\ActivityFixtures;
|
||||
use App\Tests\DataFixtures\CustomerFixtures;
|
||||
use App\Tests\DataFixtures\ProjectFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
*/
|
||||
class ProjectDetailsControllerTest extends ControllerBaseTest
|
||||
{
|
||||
public function testReportIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/reporting/project_details');
|
||||
}
|
||||
|
||||
public function testReport()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
$customers = new CustomerFixtures();
|
||||
$customers->setIsVisible(true);
|
||||
$customers->setAmount(1);
|
||||
$customers = $this->importFixture($customers);
|
||||
|
||||
$projects = new ProjectFixtures();
|
||||
$projects->setCustomers($customers);
|
||||
$projects->setAmount(1);
|
||||
$projects->setIsVisible(true);
|
||||
$projects = $this->importFixture($projects);
|
||||
|
||||
$activities = new ActivityFixtures();
|
||||
$activities->setAmount(1);
|
||||
$activities->setIsGlobal(true);
|
||||
$activities = $this->importFixture($activities);
|
||||
|
||||
$timesheets = new TimesheetFixtures();
|
||||
$timesheets->setAmount(50);
|
||||
$timesheets->setActivities($activities);
|
||||
$this->importFixture($timesheets);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/reporting/project_details');
|
||||
$this->assertHasNoEntriesWithFilter($client);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/reporting/project_details?project=' . $projects[0]->getId());
|
||||
$rows = $client->getCrawler()->filterXPath("//form[@id='project-details-form']");
|
||||
self::assertEquals(1, $rows->count());
|
||||
|
||||
$rows = $client->getCrawler()->filterXPath("//div[@id='reporting-content']/div[@class='nav-tabs-custom']");
|
||||
self::assertGreaterThan(1, $rows->count());
|
||||
}
|
||||
}
|
||||
@@ -527,10 +527,6 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
]
|
||||
]);
|
||||
|
||||
if (!$client->getResponse()->isRedirect()) {
|
||||
dd($response->getStatusCode(), $response->getContent());
|
||||
}
|
||||
|
||||
$this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
@@ -268,7 +268,6 @@ class UserControllerTest extends ControllerBaseTest
|
||||
'plainPassword' => ['first' => 'sdfsdf123'],
|
||||
'alias' => 'ycvyxcb',
|
||||
'title' => '34rtwrtewrt',
|
||||
'avatar' => 'asdfawer',
|
||||
'email' => '',
|
||||
]
|
||||
],
|
||||
@@ -286,7 +285,6 @@ class UserControllerTest extends ControllerBaseTest
|
||||
'plainPassword' => ['first' => 'sdfsdf123', 'second' => 'sdfxxxxxxx'],
|
||||
'alias' => 'ycvyxcb',
|
||||
'title' => '34rtwrtewrt',
|
||||
'avatar' => 'asdfawer',
|
||||
'email' => 'ydfbvsdfgs',
|
||||
]
|
||||
],
|
||||
@@ -304,7 +302,6 @@ class UserControllerTest extends ControllerBaseTest
|
||||
'plainPassword' => ['first' => 'test123', 'second' => 'test123'],
|
||||
'alias' => 'ycvyxcb',
|
||||
'title' => '34rtwrtewrt',
|
||||
'avatar' => 'asdfawer',
|
||||
'email' => 'ydfbvsdfgs@example.com',
|
||||
]
|
||||
],
|
||||
|
||||
@@ -233,7 +233,11 @@ final class TimesheetFixtures implements TestFixture
|
||||
}
|
||||
|
||||
$faker = Factory::create();
|
||||
$user = $this->user;
|
||||
|
||||
$users = [$this->user];
|
||||
if ($this->user === null) {
|
||||
$users = $this->getAllUsers($manager);
|
||||
}
|
||||
|
||||
$tags = $this->getTagObjectList();
|
||||
foreach ($tags as $tag) {
|
||||
@@ -251,6 +255,7 @@ final class TimesheetFixtures implements TestFixture
|
||||
}
|
||||
}
|
||||
|
||||
$user = $users[array_rand($users)];
|
||||
$activity = $activities[array_rand($activities)];
|
||||
$project = $activity->getProject();
|
||||
|
||||
@@ -277,6 +282,7 @@ final class TimesheetFixtures implements TestFixture
|
||||
for ($i = 0; $i < $this->running; $i++) {
|
||||
$activity = $activities[array_rand($activities)];
|
||||
$project = $activity->getProject();
|
||||
$user = $users[array_rand($users)];
|
||||
|
||||
if (null === $project) {
|
||||
$project = $projects[array_rand($projects)];
|
||||
@@ -370,6 +376,22 @@ final class TimesheetFixtures implements TestFixture
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ObjectManager $manager
|
||||
* @return array<int|string, User>
|
||||
*/
|
||||
private function getAllUsers(ObjectManager $manager): array
|
||||
{
|
||||
$all = [];
|
||||
/** @var User[] $entries */
|
||||
$entries = $manager->getRepository(User::class)->findAll();
|
||||
foreach ($entries as $temp) {
|
||||
$all[$temp->getId()] = $temp;
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @param Activity $activity
|
||||
|
||||
@@ -175,7 +175,9 @@ class AppExtensionTest extends TestCase
|
||||
'background_color' => '#d2d6de',
|
||||
],
|
||||
'colors_limited' => true,
|
||||
'color_choices' => 'Silver|#c0c0c0,Gray|#808080,Black|#000000,Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,Gold|#ffd700,Yellow|#ffff00,Peach|#ffdab9,Khaki|#f0e68c,Olive|#808000,Lime|#00ff00,Jelly|#9acd32,Green|#008000,Teal|#008080,Aqua|#00ffff,LightBlue|#add8e6,DeepSky|#00bfff,Dodger|#1e90ff,Blue|#0000ff,Navy|#000080,Purple|#800080,Fuchsia|#ff00ff,Violet|#ee82ee,Rose|#ffe4e1,Lavender|#E6E6FA'
|
||||
'color_choices' => 'Silver|#c0c0c0,Gray|#808080,Black|#000000,Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,Gold|#ffd700,Yellow|#ffff00,Peach|#ffdab9,Khaki|#f0e68c,Olive|#808000,Lime|#00ff00,Jelly|#9acd32,Green|#008000,Teal|#008080,Aqua|#00ffff,LightBlue|#add8e6,DeepSky|#00bfff,Dodger|#1e90ff,Blue|#0000ff,Navy|#000080,Purple|#800080,Fuchsia|#ff00ff,Violet|#ee82ee,Rose|#ffe4e1,Lavender|#E6E6FA',
|
||||
'random_colors' => true,
|
||||
'avatar_url' => false,
|
||||
],
|
||||
'kimai.timesheet' => [
|
||||
'mode' => 'default',
|
||||
|
||||
@@ -371,7 +371,9 @@ class ConfigurationTest extends TestCase
|
||||
'background_color' => '#d2d6de'
|
||||
],
|
||||
'colors_limited' => true,
|
||||
'color_choices' => 'Silver|#c0c0c0,Gray|#808080,Black|#000000,Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,Gold|#ffd700,Yellow|#ffff00,Peach|#ffdab9,Khaki|#f0e68c,Olive|#808000,Lime|#00ff00,Jelly|#9acd32,Green|#008000,Teal|#008080,Aqua|#00ffff,LightBlue|#add8e6,DeepSky|#00bfff,Dodger|#1e90ff,Blue|#0000ff,Navy|#000080,Purple|#800080,Fuchsia|#ff00ff,Violet|#ee82ee,Rose|#ffe4e1,Lavender|#E6E6FA'
|
||||
'color_choices' => 'Silver|#c0c0c0,Gray|#808080,Black|#000000,Maroon|#800000,Brown|#a52a2a,Red|#ff0000,Orange|#ffa500,Gold|#ffd700,Yellow|#ffff00,Peach|#ffdab9,Khaki|#f0e68c,Olive|#808000,Lime|#00ff00,Jelly|#9acd32,Green|#008000,Teal|#008080,Aqua|#00ffff,LightBlue|#add8e6,DeepSky|#00bfff,Dodger|#1e90ff,Blue|#0000ff,Navy|#000080,Purple|#800080,Fuchsia|#ff00ff,Violet|#ee82ee,Rose|#ffe4e1,Lavender|#E6E6FA',
|
||||
'random_colors' => true,
|
||||
'avatar_url' => false,
|
||||
],
|
||||
'industry' => [
|
||||
'translation' => null,
|
||||
|
||||
@@ -60,11 +60,11 @@ class ActivityTest extends TestCase
|
||||
$this->assertEquals('hello world', $sut->getComment());
|
||||
|
||||
self::assertFalse($sut->hasColor());
|
||||
$this->assertInstanceOf(Activity::class, $sut->setColor('#fffccc'));
|
||||
$sut->setColor('#fffccc');
|
||||
$this->assertEquals('#fffccc', $sut->getColor());
|
||||
self::assertTrue($sut->hasColor());
|
||||
|
||||
$this->assertInstanceOf(Activity::class, $sut->setColor(Constants::DEFAULT_COLOR));
|
||||
$sut->setColor(Constants::DEFAULT_COLOR);
|
||||
$this->assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
|
||||
@@ -74,11 +74,11 @@ class CustomerTest extends TestCase
|
||||
self::assertEquals('hello world', $sut->getComment());
|
||||
|
||||
self::assertFalse($sut->hasColor());
|
||||
self::assertInstanceOf(Customer::class, $sut->setColor('#fffccc'));
|
||||
$sut->setColor('#fffccc');
|
||||
self::assertEquals('#fffccc', $sut->getColor());
|
||||
self::assertTrue($sut->hasColor());
|
||||
|
||||
self::assertInstanceOf(Customer::class, $sut->setColor(Constants::DEFAULT_COLOR));
|
||||
$sut->setColor(Constants::DEFAULT_COLOR);
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
|
||||
@@ -85,11 +85,11 @@ class ProjectTest extends TestCase
|
||||
self::assertEquals('a comment', $sut->getComment());
|
||||
|
||||
self::assertFalse($sut->hasColor());
|
||||
self::assertInstanceOf(Project::class, $sut->setColor('#fffccc'));
|
||||
$sut->setColor('#fffccc');
|
||||
self::assertEquals('#fffccc', $sut->getColor());
|
||||
self::assertTrue($sut->hasColor());
|
||||
|
||||
self::assertInstanceOf(Project::class, $sut->setColor(Constants::DEFAULT_COLOR));
|
||||
$sut->setColor(Constants::DEFAULT_COLOR);
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class TagTest extends TestCase
|
||||
$sut->setName(null);
|
||||
$this->assertNull($sut->getName());
|
||||
|
||||
$this->assertInstanceOf(Tag::class, $sut->setColor('#fffccc'));
|
||||
$sut->setColor('#fffccc');
|
||||
$this->assertEquals('#fffccc', $sut->getColor());
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
@@ -38,6 +39,21 @@ class TeamTest extends TestCase
|
||||
self::assertEquals(0, $sut->getActivities()->count());
|
||||
}
|
||||
|
||||
public function testColor()
|
||||
{
|
||||
$sut = new Team();
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
$sut->setColor(Constants::DEFAULT_COLOR);
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
$sut->setColor('#000000');
|
||||
self::assertEquals('#000000', $sut->getColor());
|
||||
self::assertTrue($sut->hasColor());
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$sut = new Team();
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Tests\Entity;
|
||||
|
||||
use App\Constants;
|
||||
use App\Entity\Team;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
@@ -60,6 +61,21 @@ class UserTest extends TestCase
|
||||
self::assertEquals('Mr. Code Blaster', $user->getTitle());
|
||||
}
|
||||
|
||||
public function testColor()
|
||||
{
|
||||
$sut = new User();
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
$sut->setColor(Constants::DEFAULT_COLOR);
|
||||
self::assertNull($sut->getColor());
|
||||
self::assertFalse($sut->hasColor());
|
||||
|
||||
$sut->setColor('#000000');
|
||||
self::assertEquals('#000000', $sut->getColor());
|
||||
self::assertTrue($sut->hasColor());
|
||||
}
|
||||
|
||||
public function testAuth()
|
||||
{
|
||||
$user = new User();
|
||||
@@ -317,6 +333,7 @@ class UserTest extends TestCase
|
||||
['label.active', 'boolean'],
|
||||
['profile.registration_date', 'datetime'],
|
||||
['label.roles', 'array'],
|
||||
['label.color', 'string'],
|
||||
];
|
||||
|
||||
self::assertCount(\count($expected), $columns);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user