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:
Kevin Papst
2021-07-06 22:01:20 +02:00
committed by GitHub
parent cc6031bfde
commit 9ddb87a6fd
122 changed files with 2736 additions and 1549 deletions

View File

@@ -29,3 +29,4 @@
@import 'forms';
@import 'modal';
@import 'progressbar';
@import 'avatar';

78
assets/sass/avatar.scss Normal file
View 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ $blue: #0073b7;
//$maroon: #D81B60;
//$black: #111;
$gray-lte: #d2d6de;
$kimai-default: $gray-lte;
// LAYOUT
// --------------------------------------------------------

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -6,7 +6,7 @@
"build/app.1cecb23c.js"
],
"css": [
"build/app.cf7b7f85.css"
"build/app.079fbef2.css"
]
},
"invoice": {

View File

@@ -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",

View File

@@ -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');

View File

@@ -43,7 +43,6 @@ class DoctorController extends AbstractController
'var/data/',
'var/log/',
'var/sessions/',
'public/avatars/',
];
/**

View File

@@ -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(),

View 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(),
]);
}
}

View File

@@ -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)

View File

@@ -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');

View File

@@ -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()
;

View File

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

View File

@@ -129,6 +129,8 @@ class Team
*/
private $activities;
use ColorTrait;
public function __construct()
{
$this->users = new ArrayCollection();

View File

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

View File

@@ -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']);
}
}
}

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ActivityTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

51
src/Form/ColorTrait.php Normal file
View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\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,
]);
}
);
}
}

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

View File

@@ -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, [

View File

@@ -17,8 +17,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectTeamPermissionForm extends AbstractType
{
use EntityFormTrait;
/**
* {@inheritdoc}
*/

View File

@@ -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);
}
/**

View File

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

View File

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

View File

@@ -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',
])

View 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');
}
}

View File

@@ -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(),
]);
}
}

View File

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

View File

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

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

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

View File

@@ -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)
{

View File

@@ -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,
];
}
}

View 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());
}
}

View File

@@ -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[]

View 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',
]);
}
}

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

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

View File

@@ -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'));
}

View File

@@ -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'));

View File

@@ -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
*/

View File

@@ -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');
}
}

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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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']),
];
}
}

View File

@@ -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');
}
}

View File

@@ -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] !== '#') {

View File

@@ -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"
},

View File

@@ -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 %}

View File

@@ -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') }}">

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -88,8 +88,9 @@
{% endif %}
</td>
<td align="right">
{% if kimai_context.branding.company is not empty %}
{{ kimai_context.branding.company|raw }} &ndash; {{ now|date_full }}
{% set company = config('theme.branding.company') %}
{% if company is not empty %}
{{ company|raw }} &ndash; {{ 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 %}

View File

@@ -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 %}

View 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View 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 %}
&ndash;
{% 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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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') }}">

View File

@@ -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) }}&nbsp;

View File

@@ -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 %}

View 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 %}

View File

@@ -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) }}

View File

@@ -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 %}

View File

@@ -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 -%}

View File

@@ -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 %}
],

View File

@@ -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'],

View File

@@ -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()

View File

@@ -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']);

View File

@@ -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');
}

View File

@@ -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());
}

View File

@@ -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());
}

View 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());
}
}

View File

@@ -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());

View File

@@ -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',
]
],

View File

@@ -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

View File

@@ -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',

View File

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

View File

@@ -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());

View File

@@ -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());

View File

@@ -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());

View File

@@ -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());
}

View File

@@ -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();

View File

@@ -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