detail pages for customers and projects (#1371)

This commit is contained in:
Kevin Papst
2020-01-16 16:25:28 +01:00
committed by GitHub
parent 28c5357f11
commit 6a44dbfe83
131 changed files with 3055 additions and 1742 deletions

View File

@@ -12,18 +12,28 @@ Perform EACH version specific task between your version and the new one, otherwi
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** **New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
New permissions:
- `comments_customer` - show comment list on customer detail page (new feature)
- `details_customer` - show detail information for customers (customer number, vat, rates, meta-fields, assigned teams ...)
- `comments_project` - show comment list on project detail page (new feature)
- `details_project` - show detail information for projects (rates, meta-fields, assigned teams ...)
If you are using teams, please read on: The following list of permissions are now also available in the UI and they (can) replace the `X_project` and `X_customer` permissions.
They are more strict, as they allow only access to team specific items, the older permissions without `_teamlead_`/`_team_` work on a global level instead.
- `view_teamlead_customer`, `edit_teamlead_customer`, `budget_teamlead_customer`, `permissions_teamlead_customer`, `comments_teamlead_customer`, `details_teamlead_customer` - allows access to customer data when user is teamlead of a team assigned to the customer (replaces more global permission like `view_customer` for teamleads)
- `view_team_customer`, `edit_team_customer`, `budget_team_customer`, `comments_team_customer`, `details_team_customer` - allows access to customer data when user is member of a team assigned to the customer (replaces more global permission like `view_customer` for users)
- `view_teamlead_project`, `edit_teamlead_project`, `budget_teamlead_project`, `permissions_teamlead_project`, `comments_teamlead_project`, `details_teamlead_project` - allows access to customer data when user is teamlead of a team assigned to the project (replaces more global permission like `view_project` for teamleads)
- `view_team_project`, `edit_team_project`, `budget_team_project`, `comments_team_project`, `details_team_project` - allows access to customer data when user is member of a team assigned to the project (replaces more global permission like `view_project` for users)
### ExpenseBundle ### ExpenseBundle
**ATTENTION** due to incompatibilities in the underlying frameworks users of the ExpenseBundle need to do one more step: **ATTENTION** due to incompatibilities in the underlying frameworks users of the ExpenseBundle need to do one more step:
If you use the ExpensePlugin, you should delete it before updating: `rm -r var/plugins/ExpenseBundle`. You need to delete the bundle before updating: `rm -r var/plugins/ExpenseBundle`, otherwise you will run into errors during the update.
You will run into an error otherwise during the update.
After the Kimai update was successful, you have to re-install the latest version, which is compatible with Kimai 1.7 only. After the Kimai update was successful, you have to re-install the latest bundle version, which is compatible with Kimai 1.7 only.
### Hosting
- New feature requires the timezone date within MySQL/MariaDB. This is especially important for self-hosted systems: [you need to import the data manually](https://mariadb.com/kb/en/library/mysql_tzinfo_to_sql/).
### Developer ### Developer

View File

@@ -74,6 +74,7 @@ require('./sass/app.scss');
// ------ Kimai itself ------ // ------ Kimai itself ------
require('./js/KimaiWebLoader.js'); require('./js/KimaiWebLoader.js');
global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default; global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default;
global.KimaiReloadPageWidget = require('./js/widgets/KimaiReloadPageWidget').default;
// ------ Autocomplete for tags only ------ // ------ Autocomplete for tags only ------
require('jquery-ui/ui/widgets/autocomplete'); require('jquery-ui/ui/widgets/autocomplete');

View File

@@ -0,0 +1,72 @@
/*
* 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.
*/
/*!
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
*/
import jQuery from "jquery";
export default class KimaiReloadPageWidget {
constructor(events, fullReload) {
this.overlay = jQuery('<div class="overlay-wrapper"><div class="overlay"><div class="fa fa-refresh fa-spin"></div></div></div>');
this.widget = jQuery('div.content-wrapper');
const self = this;
const reloadPage = function (event) {
if (fullReload) {
document.location.reload(true);
} else {
self.loadPage(document.location);
}
};
for (const eventName of events.split(' ')) {
document.addEventListener(eventName, reloadPage);
}
}
static create(events, fullReload) {
if (fullReload === undefined || fullReload === null) {
fullReload = false;
}
return new KimaiReloadPageWidget(events, fullReload);
}
_showOverlay() {
this.widget.append(this.overlay);
}
_hideOverlay() {
jQuery(this.overlay).remove();
}
loadPage(url) {
const self = this;
self._showOverlay();
jQuery.ajax({
url: url,
data: {},
success: function (response) {
jQuery('section.content').replaceWith(
jQuery(response).find('section.content')
);
self._hideOverlay();
},
dataType: 'html',
error: function(jqXHR, textStatus, errorThrown) {
self._hideOverlay();
document.location = url;
}
});
}
}

View File

@@ -49,3 +49,8 @@ td {
.label-gray { .label-gray {
background-color: $gray-lte; background-color: $gray-lte;
} }
/* Delete link in action dropdowns */
.dropdown-menu > li.delete > a {
color: #dd4b39;
}

View File

@@ -23,10 +23,7 @@ span.label-customer {
table.dataTable { table.dataTable {
/* action column */ /* action column */
.actions { .actions {
width: 90px; width: 40px;
li.delete a {
color: #dd4b39;
}
} }
tr { tr {
/* summary row - provided in the users own timesheet table */ /* summary row - provided in the users own timesheet table */

View File

@@ -279,5 +279,8 @@ table.dataTable.table > tbody > tr > td {
.img-circle { .img-circle {
max-width: 35px; max-width: 35px;
margin-right: 10px; margin-right: 10px;
&.teamlead {
box-shadow: 0 0 5px 3px $gray-lte;
}
} }
} }

View File

@@ -1,23 +1,24 @@
#!/usr/bin/env php #!/usr/bin/env php
<?php <?php
set_time_limit(0);
if (!file_exists(__DIR__.'/../vendor/autoload.php')) {
echo 'Warning: You need to run "composer install" before you can use the console.' . \PHP_EOL;
die(1);
}
require __DIR__.'/../vendor/autoload.php';
use App\Kernel; use App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Debug\Debug; use Symfony\Component\Debug\Debug;
use Symfony\Component\Dotenv\Dotenv; use Symfony\Component\Dotenv\Dotenv;
set_time_limit(0);
require __DIR__.'/../vendor/autoload.php';
if (!class_exists(Application::class)) {
throw new \RuntimeException('You need to add "symfony/framework-bundle" as a Composer dependency.');
}
if (!isset($_SERVER['APP_ENV'])) { if (!isset($_SERVER['APP_ENV'])) {
if (!class_exists(Dotenv::class)) { if (!class_exists(Dotenv::class)) {
throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables for configuration or add "symfony/dotenv" as a Composer dependency to load variables from a .env file.'); throw new \RuntimeException('APP_ENV environment variable is not defined. You need to define environment variables or change the variables in your .env file.');
} }
(new Dotenv(true))->load(__DIR__.'/../.env'); (new Dotenv(true))->load(__DIR__.'/../.env');
} }

View File

@@ -64,10 +64,10 @@
"doctrine/doctrine-fixtures-bundle": "^3.2", "doctrine/doctrine-fixtures-bundle": "^3.2",
"friendsofphp/php-cs-fixer": "^2.10", "friendsofphp/php-cs-fixer": "^2.10",
"fzaninotto/faker": "^1.8", "fzaninotto/faker": "^1.8",
"phpstan/phpstan": "^0.11.7", "phpstan/phpstan": "^0.12",
"phpstan/phpstan-doctrine": "^0.11.4", "phpstan/phpstan-doctrine": "^0.12",
"phpstan/phpstan-phpunit": "^0.11.2", "phpstan/phpstan-phpunit": "^0.12",
"phpstan/phpstan-symfony": "^0.11.6", "phpstan/phpstan-symfony": "^0.12",
"phpunit/phpunit": "^8.0", "phpunit/phpunit": "^8.0",
"symfony/browser-kit": "^4.0", "symfony/browser-kit": "^4.0",
"symfony/css-selector": "^4.0", "symfony/css-selector": "^4.0",

915
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -86,17 +86,21 @@ kimai:
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"]) # mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and sets"])
sets: sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity'] ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity'] ACTIVITIES_TEAMLEAD: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project'] PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project','comments_project','details_project']
PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project'] PROJECTS_ALL_TEAMLEAD: ['view_teamlead_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project','comments_teamlead_project','details_teamlead_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer'] PROJECTS_ALL_TEAM: ['view_team_project','edit_team_project','budget_team_project','comments_team_project','details_team_project']
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer'] PROJECTS_TEAMLEAD: ['view_teamlead_project','edit_teamlead_project','budget_teamlead_project','comments_teamlead_project','details_teamlead_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer','comments_customer','details_customer']
CUSTOMERS_ALL_TEAMLEAD: ['view_teamlead_customer','edit_teamlead_customer','budget_teamlead_customer','permissions_teamlead_customer','comments_teamlead_customer','details_teamlead_customer']
CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','details_team_customer']
CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','edit_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','details_teamlead_customer']
INVOICE: ['view_invoice','create_invoice'] INVOICE: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['manage_invoice_template'] INVOICE_TEMPLATE: ['manage_invoice_template']
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet'] TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet'] TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile'] PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile'] PROFILE_OTHER: ['view_other_profile','edit_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','teams_other_profile']
TAGS: ['view_tag','manage_tag','delete_tag'] TAGS: ['view_tag','manage_tag','delete_tag']
USER: ['view_user','create_user','delete_user','role_permissions'] USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet'] RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
@@ -107,10 +111,10 @@ kimai:
SINGLE_USER: ['view_team_member','budget_team_project'] SINGLE_USER: ['view_team_member','budget_team_project']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member'] SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member'] SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member'] SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member']
# link above sets to one complete set for each user role # link above sets to one complete set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER'] ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD'] ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN'] ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN'] ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"]) # mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
@@ -119,6 +123,8 @@ kimai:
ROLE_TEAMLEAD: ['ROLE_TEAMLEAD'] ROLE_TEAMLEAD: ['ROLE_TEAMLEAD']
ROLE_ADMIN: ['ROLE_ADMIN'] ROLE_ADMIN: ['ROLE_ADMIN']
ROLE_SUPER_ADMIN: ['ROLE_SUPER_ADMIN'] ROLE_SUPER_ADMIN: ['ROLE_SUPER_ADMIN']
# only here to register the (partially) unused permissions in the UI
ROLE_FAKE: ['CUSTOMERS_ALL_TEAMLEAD','CUSTOMERS_ALL_TEAM','PROJECTS_ALL_TEAMLEAD','PROJECTS_ALL_TEAM']
# add or remove single permissions # add or remove single permissions
roles: roles:
ROLE_USER: [] ROLE_USER: []

View File

@@ -1,6 +1,7 @@
includes: includes:
- vendor/phpstan/phpstan-symfony/extension.neon - vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon - vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon
parameters: parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan tmpDir: %rootDir%/../../../var/cache/phpstan
@@ -10,15 +11,15 @@ parameters:
# container_xml_path: '%rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml' # container_xml_path: '%rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml'
ignoreErrors: ignoreErrors:
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::scalarNode\(\).#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::scalarNode\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\).#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::integerNode\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::useAttributeAsKey\(\).#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::booleanNode\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::end\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::defaultValue\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::addDefaultsIfNotSet\(\).#' - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::addDefaultsIfNotSet\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::requiresAtLeastOneElement\(\).#'
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#' - '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#' - '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
- '#Access to an undefined property Faker\\Generator::\$bs.#' - '#Access to an undefined property Faker\\Generator::\$bs.#'
- '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#' - '#Method Symfony\\Contracts\\EventDispatcher\\EventDispatcherInterface::dispatch\(\) invoked with 2 parameters, 1 required.#'
- '#Method App\\Controller\\AbstractController::getUser\(\) should return App\\Entity\\User|null but returns object|null. #'
excludes_analyse: excludes_analyse:
- %rootDir%/../../../src/Command/KimaiImporterCommand.php - %rootDir%/../../../src/Command/KimaiImporterCommand.php
- %rootDir%/../../../src/Ldap/LdapDriver.php - %rootDir%/../../../src/Ldap/LdapDriver.php

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
"build/runtime.6ca1eea5.js", "build/runtime.6ca1eea5.js",
"build/0.1a6ffb43.js", "build/0.1a6ffb43.js",
"build/1.16767f92.js", "build/1.16767f92.js",
"build/app.055e71d1.js" "build/app.6ea4e95f.js"
], ],
"css": [ "css": [
"build/app.483d7949.css" "build/app.8240996d.css"
] ]
}, },
"chart": { "chart": {
@@ -35,8 +35,8 @@
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD", "build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG", "build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.055e71d1.js": "sha384-NGXBt+8vQ5h4orQ5B82W9GoeVpu4bNmiJI7k/goKm1ogp2JGvo0tyadJdoOf3Dt+", "build/app.6ea4e95f.js": "sha384-mHnCng8TBnNQoqH7ZSnkexDEYgODkjaDJIN5BpK6dSAP7dhIcQ5dUY19NtgfjUsZ",
"build/app.483d7949.css": "sha384-u8/TZyq87MTQTKScxUGmbJcWEBhsoXAlsUTB8XSgKvuufZ5bODWYm1o1E1DRosJu", "build/app.8240996d.css": "sha384-ZaS4ro6HGbDfLyaJc8cVXJzTd8plX3mRzhHhiiUEErSzPlRKdZfViDk23BNvAaz+",
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL", "build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1", "build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1",

View File

@@ -2,8 +2,8 @@
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js", "build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
"build/1.16767f92.js": "build/1.16767f92.js", "build/1.16767f92.js": "build/1.16767f92.js",
"build/2.dad56560.js": "build/2.dad56560.js", "build/2.dad56560.js": "build/2.dad56560.js",
"build/app.css": "build/app.483d7949.css", "build/app.css": "build/app.8240996d.css",
"build/app.js": "build/app.055e71d1.js", "build/app.js": "build/app.6ea4e95f.js",
"build/calendar.css": "build/calendar.ade7bcdf.css", "build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.9e7cdb6a.js", "build/calendar.js": "build/calendar.9e7cdb6a.js",
"build/chart.js": "build/chart.7f4d7f29.js", "build/chart.js": "build/chart.7f4d7f29.js",

View File

@@ -23,6 +23,10 @@ class ThemeConfiguration implements SystemBundleConfiguration, \ArrayAccess
return (bool) $this->find('auto_reload_datatable'); return (bool) $this->find('auto_reload_datatable');
} }
/**
* Currently unused, as JS selects are always activated.
* @deprecated since 1.7 will be removed with 2.0
*/
public function getSelectPicker(): string public function getSelectPicker(): string
{ {
return (string) $this->find('select_type'); return (string) $this->find('select_type');

View File

@@ -11,17 +11,24 @@ namespace App\Controller;
use App\Configuration\FormConfiguration; use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Team;
use App\Event\CustomerMetaDefinitionEvent; use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
use App\Form\CustomerCommentForm;
use App\Form\CustomerEditForm; use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm; use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery; use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -34,42 +41,28 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to manage customer in the admin part of the site. * Controller used to manage customer in the admin part of the site.
* *
* @Route(path="/admin/customer") * @Route(path="/admin/customer")
* @Security("is_granted('view_customer')") * @Security("is_granted('view_customer') or is_granted('view_teamlead_customer') or is_granted('view_team_customer')")
*/ */
class CustomerController extends AbstractController final class CustomerController extends AbstractController
{ {
/** /**
* @var CustomerRepository * @var CustomerRepository
*/ */
private $repository; private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/** /**
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; private $dispatcher;
public function __construct(CustomerRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) public function __construct(CustomerRepository $repository, EventDispatcherInterface $dispatcher)
{ {
$this->repository = $repository; $this->repository = $repository;
$this->configuration = $configuration;
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
} }
/**
* @return \App\Repository\CustomerRepository
*/
protected function getRepository()
{
return $this->repository;
}
/** /**
* @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"}) * @Route(path="/", defaults={"page": 1}, name="admin_customer", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')")
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
@@ -85,7 +78,7 @@ class CustomerController extends AbstractController
$query->resetByFormError($form->getErrors()); $query->resetByFormError($form->getErrors());
} }
$entries = $this->getRepository()->getPagerfantaForQuery($query); $entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('customer/index.html.twig', [ return $this->render('customer/index.html.twig', [
'entries' => $entries, 'entries' => $entries,
@@ -99,7 +92,7 @@ class CustomerController extends AbstractController
* @param CustomerQuery $query * @param CustomerQuery $query
* @return MetaTableTypeInterface[] * @return MetaTableTypeInterface[]
*/ */
protected function findMetaColumns(CustomerQuery $query): array private function findMetaColumns(CustomerQuery $query): array
{ {
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER); $event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
@@ -111,16 +104,16 @@ class CustomerController extends AbstractController
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"}) * @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')") * @Security("is_granted('create_customer')")
*/ */
public function createAction(Request $request) public function createAction(Request $request, FormConfiguration $configuration)
{ {
$timezone = date_default_timezone_get(); $timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) { if (null !== $configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone(); $timezone = $configuration->getCustomerDefaultTimezone();
} }
$customer = new Customer(); $customer = new Customer();
$customer->setCountry($this->configuration->getCustomerDefaultCountry()); $customer->setCountry($configuration->getCustomerDefaultCountry());
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency()); $customer->setCurrency($configuration->getCustomerDefaultCurrency());
$customer->setTimezone($timezone); $customer->setTimezone($timezone);
return $this->renderCustomerForm($customer, $request); return $this->renderCustomerForm($customer, $request);
@@ -130,7 +123,7 @@ class CustomerController extends AbstractController
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"}) * @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @Security("is_granted('permissions', customer)") * @Security("is_granted('permissions', customer)")
*/ */
public function teamPermissions(Customer $customer, Request $request) public function teamPermissionsAction(Customer $customer, Request $request)
{ {
$form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [ $form = $this->createForm(CustomerTeamPermissionForm::class, $customer, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]), 'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
@@ -141,11 +134,11 @@ class CustomerController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$this->getRepository()->saveCustomer($customer); $this->repository->saveCustomer($customer);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer'); return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -157,18 +150,160 @@ class CustomerController extends AbstractController
} }
/** /**
* @Route(path="/{id}/budget", name="admin_customer_budget", methods={"GET"}) * @Route(path="/{id}/comment_delete", name="customer_comment_delete", methods={"GET"})
* @Security("is_granted('budget', customer)") * @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
*/ */
public function budgetAction(Customer $customer) public function deleteCommentAction(CustomerComment $comment)
{ {
$stats = $this->getRepository()->getCustomerStatistics($customer); $customerId = $comment->getCustomer()->getId();
// TODO sent event with stats try {
$this->repository->deleteComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->render('customer/budget.html.twig', [ return $this->redirectToRoute('customer_details', ['id' => $customerId]);
}
/**
* @Route(path="/{id}/comment_add", name="customer_comment_add", methods={"POST"})
* @Security("is_granted('edit', customer) and is_granted('comments', customer)")
*/
public function addCommentAction(Customer $customer, Request $request)
{
$comment = new CustomerComment();
$form = $this->getCommentForm($customer, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
/**
* @Route(path="/{id}/comment_pin", name="customer_comment_pin", methods={"GET"})
* @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
*/
public function pinCommentAction(CustomerComment $comment)
{
$comment->setPinned(!$comment->isPinned());
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('customer_details', ['id' => $comment->getCustomer()->getId()]);
}
/**
* @Route(path="/{id}/create_team", name="customer_team_create", methods={"GET"})
* @Security("is_granted('create_team') and is_granted('permissions', customer)")
*/
public function createDefaultTeamAction(Customer $customer, TeamRepository $teamRepository)
{
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
$defaultTeam = new Team();
$defaultTeam->setName($customer->getName());
$defaultTeam->setTeamLead($this->getUser());
$defaultTeam->addCustomer($customer);
try {
$teamRepository->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
/**
* @Route(path="/{id}/projects/{page}", defaults={"page": 1}, name="customer_projects", methods={"GET", "POST"})
* @Security("is_granted('view', customer)")
*/
public function projectsAction(Customer $customer, int $page, ProjectRepository $projectRepository)
{
$query = new ProjectQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$query->setPageSize(5);
$query->setCustomer($customer);
/* @var $entries Pagerfanta */
$entries = $projectRepository->getPagerfantaForQuery($query);
return $this->render('customer/embed_projects.html.twig', [
'customer' => $customer, 'customer' => $customer,
'projects' => $entries,
'page' => $page,
]);
}
/**
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
* @Security("is_granted('view', customer)")
*/
public function detailsAction(Customer $customer, TeamRepository $teamRepository)
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$stats = null;
$timezone = null;
$defaultTeam = null;
$commentForm = null;
$attachments = [];
$comments = null;
$teams = null;
$projects = null;
if ($this->isGranted('edit', $customer)) {
$commentForm = $this->getCommentForm($customer, new CustomerComment())->createView();
if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
}
}
if (null !== $customer->getTimezone()) {
$timezone = new \DateTimeZone($customer->getTimezone());
}
if ($this->isGranted('budget', $customer)) {
$stats = $this->repository->getCustomerStatistics($customer);
}
if ($this->isGranted('comments', $customer)) {
$comments = $this->repository->getComments($customer);
}
if ($this->isGranted('permissions', $customer) || $this->isGranted('details', $customer) || $this->isGranted('view_team')) {
$teams = $customer->getTeams();
}
return $this->render('customer/details.html.twig', [
'customer' => $customer,
'comments' => $comments,
'commentForm' => $commentForm,
'attachments' => $attachments,
'stats' => $stats, 'stats' => $stats,
'team' => $defaultTeam,
'teams' => $teams,
'now' => new \DateTime('now', $timezone),
]); ]);
} }
@@ -187,7 +322,7 @@ class CustomerController extends AbstractController
*/ */
public function deleteAction(Customer $customer, Request $request) public function deleteAction(Customer $customer, Request $request)
{ {
$stats = $this->getRepository()->getCustomerStatistics($customer); $stats = $this->repository->getCustomerStatistics($customer);
$deleteForm = $this->createFormBuilder(null, [ $deleteForm = $this->createFormBuilder(null, [
'attr' => [ 'attr' => [
@@ -215,7 +350,7 @@ class CustomerController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try { try {
$this->getRepository()->deleteCustomer($customer, $deleteForm->get('customer')->getData()); $this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (ORMException $ex) { } catch (ORMException $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
@@ -236,21 +371,18 @@ class CustomerController extends AbstractController
* @param Request $request * @param Request $request
* @return RedirectResponse|Response * @return RedirectResponse|Response
*/ */
protected function renderCustomerForm(Customer $customer, Request $request) private function renderCustomerForm(Customer $customer, Request $request)
{ {
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
$editForm = $this->createEditForm($customer); $editForm = $this->createEditForm($customer);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->getRepository()->saveCustomer($customer); $this->repository->saveCustomer($customer);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer'); return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (ORMException $ex) { } catch (ORMException $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
@@ -262,7 +394,7 @@ class CustomerController extends AbstractController
]); ]);
} }
protected function getToolbarForm(CustomerQuery $query): FormInterface private function getToolbarForm(CustomerQuery $query): FormInterface
{ {
return $this->createForm(CustomerToolbarForm::class, $query, [ return $this->createForm(CustomerToolbarForm::class, $query, [
'action' => $this->generateUrl('admin_customer', [ 'action' => $this->generateUrl('admin_customer', [
@@ -272,8 +404,24 @@ class CustomerController extends AbstractController
]); ]);
} }
private function getCommentForm(Customer $customer, CustomerComment $comment): FormInterface
{
if (null === $comment->getId()) {
$comment->setCustomer($customer);
$comment->setCreatedBy($this->getUser());
}
return $this->createForm(CustomerCommentForm::class, $comment, [
'action' => $this->generateUrl('customer_comment_add', ['id' => $customer->getId()]),
'method' => 'POST',
]);
}
private function createEditForm(Customer $customer): FormInterface private function createEditForm(Customer $customer): FormInterface
{ {
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
if ($customer->getId() === null) { if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create'); $url = $this->generateUrl('admin_customer_create');
} else { } else {

View File

@@ -13,16 +13,21 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\Team;
use App\Event\ProjectMetaDefinitionEvent; use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Form\ProjectCommentForm;
use App\Form\ProjectEditForm; use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm; use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery; use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException; use App\Repository\TeamRepository;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -33,12 +38,12 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
/** /**
* Controller used to manage projects in the admin part of the site. * Controller used to manage projects.
* *
* @Route(path="/admin/project") * @Route(path="/admin/project")
* @Security("is_granted('view_project')") * @Security("is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project')")
*/ */
class ProjectController extends AbstractController final class ProjectController extends AbstractController
{ {
/** /**
* @var ProjectRepository * @var ProjectRepository
@@ -51,7 +56,7 @@ class ProjectController extends AbstractController
/** /**
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; private $dispatcher;
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{ {
@@ -60,15 +65,9 @@ class ProjectController extends AbstractController
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
} }
protected function getRepository(): ProjectRepository
{
return $this->repository;
}
/** /**
* @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"}) * @Route(path="/", defaults={"page": 1}, name="admin_project", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated", methods={"GET"})
* @Security("is_granted('view_project')")
*/ */
public function indexAction($page, Request $request) public function indexAction($page, Request $request)
{ {
@@ -85,7 +84,7 @@ class ProjectController extends AbstractController
} }
/* @var $entries Pagerfanta */ /* @var $entries Pagerfanta */
$entries = $this->getRepository()->getPagerfantaForQuery($query); $entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('project/index.html.twig', [ return $this->render('project/index.html.twig', [
'entries' => $entries, 'entries' => $entries,
@@ -122,11 +121,11 @@ class ProjectController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
try { try {
$this->getRepository()->saveProject($project); $this->repository->saveProject($project);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project'); return $this->redirectToRoute('admin_project');
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -154,18 +153,154 @@ class ProjectController extends AbstractController
} }
/** /**
* @Route(path="/{id}/budget", name="admin_project_budget", methods={"GET"}) * @Route(path="/{id}/comment_delete", name="project_comment_delete", methods={"GET"})
* @Security("is_granted('budget', project)") * @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
*/ */
public function budgetAction(Project $project) public function deleteCommentAction(ProjectComment $comment)
{ {
$stats = $this->getRepository()->getProjectStatistics($project); $projectId = $comment->getProject()->getId();
// TODO sent event with stats try {
$this->repository->deleteComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->render('project/budget.html.twig', [ return $this->redirectToRoute('project_details', ['id' => $projectId]);
}
/**
* @Route(path="/{id}/comment_add", name="project_comment_add", methods={"POST"})
* @Security("is_granted('edit', project) and is_granted('comments', project)")
*/
public function addCommentAction(Project $project, Request $request)
{
$comment = new ProjectComment();
$form = $this->getCommentForm($project, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
/**
* @Route(path="/{id}/comment_pin", name="project_comment_pin", methods={"GET"})
* @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
*/
public function pinCommentAction(ProjectComment $comment)
{
$comment->setPinned(!$comment->isPinned());
try {
$this->repository->saveComment($comment);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('project_details', ['id' => $comment->getProject()->getId()]);
}
/**
* @Route(path="/{id}/create_team", name="project_team_create", methods={"GET"})
* @Security("is_granted('create_team') and is_granted('edit', project)")
*/
public function createDefaultTeamAction(Project $project, TeamRepository $teamRepository)
{
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
if (null !== $defaultTeam) {
$this->flashError('action.update.error', ['%reason%' => 'Team already existing']);
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
$defaultTeam = new Team();
$defaultTeam->setName($project->getName());
$defaultTeam->setTeamLead($this->getUser());
$defaultTeam->addProject($project);
try {
$teamRepository->saveTeam($defaultTeam);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
/**
* @Route(path="/{id}/activities/{page}", defaults={"page": 1}, name="project_activities", methods={"GET", "POST"})
* @Security("is_granted('view', project)")
*/
public function activitiesAction(Project $project, int $page, ActivityRepository $activityRepository)
{
$query = new ActivityQuery();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
$query->setPageSize(5);
$query->setProject($project);
$query->setExcludeGlobals(true);
/* @var $entries Pagerfanta */
$entries = $activityRepository->getPagerfantaForQuery($query);
return $this->render('project/embed_activities.html.twig', [
'project' => $project, 'project' => $project,
'stats' => $stats 'activities' => $entries,
'page' => $page,
]);
}
/**
* @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"})
* @Security("is_granted('view', project)")
*/
public function detailsAction(Project $project, TeamRepository $teamRepository)
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$stats = null;
$defaultTeam = null;
$commentForm = null;
$attachments = [];
$comments = null;
$teams = null;
if ($this->isGranted('edit', $project)) {
$commentForm = $this->getCommentForm($project, new ProjectComment())->createView();
if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
}
}
if ($this->isGranted('budget', $project)) {
$stats = $this->repository->getProjectStatistics($project);
}
if ($this->isGranted('comments', $project)) {
$comments = $this->repository->getComments($project);
}
if ($this->isGranted('permissions', $project) || $this->isGranted('details', $project) || $this->isGranted('view_team')) {
$teams = $project->getTeams();
}
return $this->render('project/details.html.twig', [
'project' => $project,
'comments' => $comments,
'commentForm' => $commentForm,
'attachments' => $attachments,
'stats' => $stats,
'team' => $defaultTeam,
'teams' => $teams,
]); ]);
} }
@@ -184,7 +319,7 @@ class ProjectController extends AbstractController
*/ */
public function deleteAction(Project $project, Request $request) public function deleteAction(Project $project, Request $request)
{ {
$stats = $this->getRepository()->getProjectStatistics($project); $stats = $this->repository->getProjectStatistics($project);
$deleteForm = $this->createFormBuilder(null, [ $deleteForm = $this->createFormBuilder(null, [
'attr' => [ 'attr' => [
@@ -213,9 +348,9 @@ class ProjectController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try { try {
$this->getRepository()->deleteProject($project, $deleteForm->get('project')->getData()); $this->repository->deleteProject($project, $deleteForm->get('project')->getData());
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
} }
@@ -234,17 +369,14 @@ class ProjectController extends AbstractController
* @param Request $request * @param Request $request
* @return RedirectResponse|Response * @return RedirectResponse|Response
*/ */
protected function renderProjectForm(Project $project, Request $request) private function renderProjectForm(Project $project, Request $request)
{ {
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$editForm = $this->createEditForm($project); $editForm = $this->createEditForm($project);
$editForm->handleRequest($request); $editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->getRepository()->saveProject($project); $this->repository->saveProject($project);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
@@ -254,9 +386,9 @@ class ProjectController extends AbstractController
$editForm->get('create_more')->setData(true); $editForm->get('create_more')->setData(true);
$project = $newProject; $project = $newProject;
} else { } else {
return $this->redirectToRoute('admin_project'); return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
} }
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -277,8 +409,24 @@ class ProjectController extends AbstractController
]); ]);
} }
private function getCommentForm(Project $project, ProjectComment $comment): FormInterface
{
if (null === $comment->getId()) {
$comment->setProject($project);
$comment->setCreatedBy($this->getUser());
}
return $this->createForm(ProjectCommentForm::class, $comment, [
'action' => $this->generateUrl('project_comment_add', ['id' => $project->getId()]),
'method' => 'POST',
]);
}
private function createEditForm(Project $project): FormInterface private function createEditForm(Project $project): FormInterface
{ {
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$currency = $this->configuration->getCustomerDefaultCurrency(); $currency = $this->configuration->getCustomerDefaultCurrency();
$url = $this->generateUrl('admin_project_create'); $url = $this->generateUrl('admin_project_create');

View File

@@ -14,7 +14,6 @@ use App\Event\SystemConfigurationEvent;
use App\Form\Model\Configuration; use App\Form\Model\Configuration;
use App\Form\Model\SystemConfiguration as SystemConfigurationModel; use App\Form\Model\SystemConfiguration as SystemConfigurationModel;
use App\Form\SystemConfigurationForm; use App\Form\SystemConfigurationForm;
use App\Form\Type\EnhancedSelectboxType;
use App\Form\Type\LanguageType; use App\Form\Type\LanguageType;
use App\Form\Type\RoundingModeType; use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType; use App\Form\Type\SkinType;
@@ -293,25 +292,20 @@ class SystemConfigurationController extends AbstractController
->setSection(SystemConfigurationModel::SECTION_THEME) ->setSection(SystemConfigurationModel::SECTION_THEME)
->setConfiguration([ ->setConfiguration([
(new Configuration()) (new Configuration())
->setName('theme.select_type') ->setName('theme.autocomplete_chars')
->setTranslationDomain('system-configuration') ->setLabel('theme.autocomplete_chars')
->setType(EnhancedSelectboxType::class) ->setType(IntegerType::class)
->setRequired(false), ->setTranslationDomain('system-configuration'),
(new Configuration()) (new Configuration())
->setName('timesheet.markdown_content') ->setName('timesheet.markdown_content')
->setLabel('theme.markdown_content') ->setLabel('theme.markdown_content')
->setType(CheckboxType::class) ->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'), ->setTranslationDomain('system-configuration'),
(new Configuration()) // TODO should that be configurable per user?
->setName('theme.autocomplete_chars')
->setLabel('theme.autocomplete_chars')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration'),
// FIXME should that be configurable per user?
/* /*
(new Configuration()) (new Configuration())
->setName('theme.auto_reload_datatable') ->setName('theme.auto_reload_datatable')
->setLabel('theme.auto_reload_datatable') // FIXME translation ->setLabel('theme.auto_reload_datatable') // TODO translation
->setType(CheckboxType::class) ->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'), ->setTranslationDomain('system-configuration'),
*/ */

View File

@@ -16,7 +16,6 @@ use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm; use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery; use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -28,7 +27,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/admin/teams") * @Route(path="/admin/teams")
* @Security("is_granted('view_team')") * @Security("is_granted('view_team')")
*/ */
class TeamController extends AbstractController final class TeamController extends AbstractController
{ {
/** /**
* @var TeamRepository * @var TeamRepository
@@ -93,6 +92,40 @@ class TeamController extends AbstractController
return $this->renderEditScreen($team, $request); return $this->renderEditScreen($team, $request);
} }
/**
* @Route(path="/{id}/edit_member", name="admin_team_member", methods={"GET", "POST"})
* @Security("is_granted('edit', team)")
*/
public function editMemberAction(Team $team, Request $request)
{
$editForm = $this->createForm(TeamEditForm::class, $team, [
'action' => $this->generateUrl('admin_team_member', ['id' => $team->getId()]),
'method' => 'POST',
]);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
// make sure that the teamlead is always part of the team, otherwise permission checks
// and filtering might not work as expected!
$team->addUser($team->getTeamLead());
$this->repository->saveTeam($team);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('team/edit_member.html.twig', [
'team' => $team,
'form' => $editForm->createView(),
]);
}
private function renderEditScreen(Team $team, Request $request): Response private function renderEditScreen(Team $team, Request $request): Response
{ {
$customerForm = null; $customerForm = null;
@@ -122,7 +155,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -142,7 +175,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }
@@ -161,7 +194,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]); return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }

View File

@@ -36,7 +36,7 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
public const BATCH_SIZE = 50; public const BATCH_SIZE = 50;
/** /**
* @return array * @return class-string[]
*/ */
public function getDependencies() public function getDependencies()
{ {

View File

@@ -46,7 +46,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
public const BATCH_SIZE = 100; public const BATCH_SIZE = 100;
/** /**
* @return array * @return class-string[]
*/ */
public function getDependencies() public function getDependencies()
{ {

View File

@@ -31,10 +31,10 @@ class Configuration implements ConfigurationInterface
public function getConfigTreeBuilder() public function getConfigTreeBuilder()
{ {
$treeBuilder = new TreeBuilder('kimai'); $treeBuilder = new TreeBuilder('kimai');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$rootNode = $treeBuilder->getRootNode(); $node = $treeBuilder->getRootNode();
$rootNode $node
->children() ->children()
->scalarNode('data_dir') ->scalarNode('data_dir')
->isRequired() ->isRequired()
@@ -75,7 +75,7 @@ class Configuration implements ConfigurationInterface
protected function getTimesheetNode() protected function getTimesheetNode()
{ {
$builder = new TreeBuilder('timesheet'); $builder = new TreeBuilder('timesheet');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -205,7 +205,7 @@ class Configuration implements ConfigurationInterface
protected function getInvoiceNode() protected function getInvoiceNode()
{ {
$builder = new TreeBuilder('invoice'); $builder = new TreeBuilder('invoice');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -232,7 +232,7 @@ class Configuration implements ConfigurationInterface
protected function getLanguagesNode() protected function getLanguagesNode()
{ {
$builder = new TreeBuilder('languages'); $builder = new TreeBuilder('languages');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -256,7 +256,7 @@ class Configuration implements ConfigurationInterface
protected function getCalendarNode() protected function getCalendarNode()
{ {
$builder = new TreeBuilder('calendar'); $builder = new TreeBuilder('calendar');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -310,7 +310,7 @@ class Configuration implements ConfigurationInterface
protected function getThemeNode() protected function getThemeNode()
{ {
$builder = new TreeBuilder('theme'); $builder = new TreeBuilder('theme');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -326,6 +326,7 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->scalarNode('select_type') ->scalarNode('select_type')
->defaultValue('selectpicker') ->defaultValue('selectpicker')
->setDeprecated()
->end() ->end()
->scalarNode('auto_reload_datatable') ->scalarNode('auto_reload_datatable')
->defaultFalse() ->defaultFalse()
@@ -374,7 +375,7 @@ class Configuration implements ConfigurationInterface
protected function getIndustryNode() protected function getIndustryNode()
{ {
$builder = new TreeBuilder('industry'); $builder = new TreeBuilder('industry');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -390,7 +391,7 @@ class Configuration implements ConfigurationInterface
protected function getUserNode() protected function getUserNode()
{ {
$builder = new TreeBuilder('user'); $builder = new TreeBuilder('user');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -411,7 +412,7 @@ class Configuration implements ConfigurationInterface
protected function getWidgetsNode() protected function getWidgetsNode()
{ {
$builder = new TreeBuilder('widgets'); $builder = new TreeBuilder('widgets');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -438,7 +439,7 @@ class Configuration implements ConfigurationInterface
protected function getDashboardNode() protected function getDashboardNode()
{ {
$builder = new TreeBuilder('dashboard'); $builder = new TreeBuilder('dashboard');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -467,7 +468,7 @@ class Configuration implements ConfigurationInterface
protected function getDefaultsNode() protected function getDefaultsNode()
{ {
$builder = new TreeBuilder('defaults'); $builder = new TreeBuilder('defaults');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node
@@ -499,7 +500,7 @@ class Configuration implements ConfigurationInterface
protected function getPermissionsNode() protected function getPermissionsNode()
{ {
$builder = new TreeBuilder('permissions'); $builder = new TreeBuilder('permissions');
/** @var ArrayNodeDefinition $rootNode */ /** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode(); $node = $builder->getRootNode();
$node $node

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
interface CommentInterface
{
public function getId(): ?int;
public function getMessage(): ?string;
public function setMessage(string $message);
public function getCreatedBy(): ?User;
public function setCreatedBy(User $createdBy);
public function getCreatedAt(): ?\DateTime;
public function setCreatedAt(\DateTime $createdAt);
public function isPinned(): bool;
public function setPinned(bool $pinned);
}

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
trait CommentTableTypeTrait
{
/**
* @var int
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(name="id", type="integer")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="message", type="text", nullable=false)
* @Assert\NotNull()
*/
private $message;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $createdBy;
/**
* @var \DateTime
*
* @ORM\Column(name="created_at", type="datetime", nullable=false)
* @Assert\NotNull()
*/
private $createdAt;
/**
* @var bool
*
* @ORM\Column(name="pinned", type="boolean", nullable=false, options={"default": false})
* @Assert\NotNull()
*/
private $pinned = false;
public function __construct()
{
$this->createdAt = new \DateTime();
}
public function getId(): ?int
{
return $this->id;
}
public function getMessage(): ?string
{
return $this->message;
}
public function setMessage(string $message)
{
$this->message = $message;
}
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(User $createdBy)
{
$this->createdBy = $createdBy;
}
public function getCreatedAt(): ?\DateTime
{
return $this->createdAt;
}
public function setCreatedAt(\DateTime $createdAt)
{
$this->createdAt = $createdAt;
}
public function isPinned(): bool
{
return $this->pinned;
}
public function setPinned(bool $pinned)
{
$this->pinned = $pinned;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_customers_comments",
* indexes={
* @ORM\Index(columns={"customer_id"})
* }
* )
*/
class CustomerComment implements CommentInterface
{
use CommentTableTypeTrait;
/**
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
public function setCustomer(Customer $customer): CustomerComment
{
$this->customer = $customer;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_projects_comments",
* indexes={
* @ORM\Index(columns={"project_id"})
* }
* )
*/
class ProjectComment implements CommentInterface
{
use CommentTableTypeTrait;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $project;
public function setProject(Project $project): ProjectComment
{
$this->project = $project;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
}

View File

@@ -93,13 +93,13 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu->addChild($timesheets); $menu->addChild($timesheets);
} }
if ($auth->isGranted('view_customer')) { if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer')); $customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'admin_customer_budget', 'admin_customer_edit', 'admin_customer_delete']); $customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'admin_customer_budget', 'admin_customer_edit', 'admin_customer_delete']);
$menu->addChild($customers); $menu->addChild($customers);
} }
if ($auth->isGranted('view_project')) { if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project')); $projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'admin_project_budget', 'admin_project_edit', 'admin_project_delete']); $projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'admin_project_budget', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects); $menu->addChild($projects);

View File

@@ -59,7 +59,7 @@ class ActivityEditForm extends AbstractType
], ],
]) ])
->add('comment', TextareaType::class, [ ->add('comment', TextareaType::class, [
'label' => 'label.comment', 'label' => 'label.description',
'required' => false, 'required' => false,
]) ])
; ;

View File

@@ -0,0 +1,47 @@
<?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\Entity\CustomerComment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerCommentForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('message', TextareaType::class, [
'label' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => CustomerComment::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_customer_comment',
'attr' => [
'data-form-event' => 'kimai.customerComment'
],
]);
}
}

View File

@@ -49,7 +49,7 @@ class CustomerEditForm extends AbstractType
'required' => false, 'required' => false,
]) ])
->add('comment', TextareaType::class, [ ->add('comment', TextareaType::class, [
'label' => 'label.comment', 'label' => 'label.description',
'required' => false, 'required' => false,
]) ])
->add('company', TextType::class, [ ->add('company', TextType::class, [

View File

@@ -9,7 +9,6 @@
namespace App\Form\Extension; namespace App\Form\Extension;
use App\Configuration\ThemeConfiguration;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractTypeExtension; use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
@@ -20,19 +19,12 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/** /**
* Converts normal select boxes into javascript enhanced versions. * Converts normal select boxes into javascript enhanced versions.
*/ */
class EnhancedChoiceTypeExtension extends AbstractTypeExtension final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
{ {
public const TYPE_SELECTPICKER = 'selectpicker';
/** /**
* @var string|null * @deprecated since 1.7 will be removed with 2.0
*/ */
protected $type = null; public const TYPE_SELECTPICKER = 'selectpicker';
public function __construct(ThemeConfiguration $configuration)
{
$this->type = $configuration->getSelectPicker();
}
public static function getExtendedTypes(): iterable public static function getExtendedTypes(): iterable
{ {
@@ -46,10 +38,6 @@ class EnhancedChoiceTypeExtension extends AbstractTypeExtension
*/ */
public function buildView(FormView $view, FormInterface $form, array $options) public function buildView(FormView $view, FormInterface $form, array $options)
{ {
if ($this->type !== self::TYPE_SELECTPICKER) {
return;
}
if (isset($options['selectpicker']) && false === $options['selectpicker']) { if (isset($options['selectpicker']) && false === $options['selectpicker']) {
return; return;
} }

View File

@@ -0,0 +1,47 @@
<?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\Entity\ProjectComment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectCommentForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('message', TextareaType::class, [
'label' => false,
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectComment::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_project_comment',
'attr' => [
'data-form-event' => 'kimai.projectComment'
],
]);
}
}

View File

@@ -38,7 +38,7 @@ class ProjectEditForm extends AbstractType
$entry = $options['data']; $entry = $options['data'];
$id = $entry->getId(); $id = $entry->getId();
if ($id !== null) { if (null !== $entry->getCustomer()) {
$customer = $entry->getCustomer(); $customer = $entry->getCustomer();
$options['currency'] = $customer->getCurrency(); $options['currency'] = $customer->getCurrency();
} }
@@ -52,7 +52,7 @@ class ProjectEditForm extends AbstractType
], ],
]) ])
->add('comment', TextareaType::class, [ ->add('comment', TextareaType::class, [
'label' => 'label.comment', 'label' => 'label.description',
'required' => false, 'required' => false,
]) ])
->add('orderNumber', TextType::class, [ ->add('orderNumber', TextType::class, [

View File

@@ -0,0 +1,65 @@
<?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 App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Creates comment tables for customers and projects
*
* @version 1.7
*/
final class Version20200109102138 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates comment tables for customers and projects';
}
public function up(Schema $schema): void
{
$customerComment = $schema->createTable('kimai2_customers_comments');
$customerComment->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$customerComment->addColumn('customer_id', 'integer', ['notnull' => true]);
$customerComment->addColumn('message', 'text', ['notnull' => true]);
$customerComment->addColumn('created_by_id', 'integer', ['notnull' => true]);
$customerComment->addColumn('created_at', 'datetime', ['notnull' => true]);
$customerComment->addColumn('pinned', 'boolean', ['notnull' => true, 'default' => false]);
$customerComment->setPrimaryKey(['id']);
$customerComment->addIndex(['customer_id'], 'IDX_A5B142D99395C3F3');
$customerComment->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_A5B142D99395C3F3');
$customerComment->addForeignKeyConstraint('kimai2_users', ['created_by_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_A5B142D9B03A8386');
$projectComment = $schema->createTable('kimai2_projects_comments');
$projectComment->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$projectComment->addColumn('project_id', 'integer', ['notnull' => true]);
$projectComment->addColumn('message', 'text', ['notnull' => true]);
$projectComment->addColumn('created_by_id', 'integer', ['notnull' => true]);
$projectComment->addColumn('created_at', 'datetime', ['notnull' => true]);
$projectComment->addColumn('pinned', 'boolean', ['notnull' => true, 'default' => false]);
$projectComment->setPrimaryKey(['id']);
$projectComment->addIndex(['project_id'], 'IDX_29A23638166D1F9C');
$projectComment->addForeignKeyConstraint('kimai2_projects', ['project_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_29A23638166D1F9C');
$projectComment->addForeignKeyConstraint('kimai2_users', ['created_by_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_29A23638B03A8386');
$this->addSql('DELETE from kimai2_configuration WHERE name = "theme.select_type"');
$this->addSql('DELETE from kimai2_roles_permissions WHERE permission = "delete_other_profile"');
$this->addSql('DELETE from kimai2_roles_permissions WHERE permission = "delete_own_profile"');
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_projects_comments');
$schema->dropTable('kimai2_customers_comments');
}
}

View File

@@ -294,12 +294,15 @@ class ActivityRepository extends EntityRepository
if ($query->isGlobalsOnly()) { if ($query->isGlobalsOnly()) {
$where->add($qb->expr()->isNull('a.project')); $where->add($qb->expr()->isNull('a.project'));
} elseif (null !== $query->getProject()) { } elseif (null !== $query->getProject()) {
$where->add( $orX = $qb->expr()->orX(
$qb->expr()->orX( $qb->expr()->eq('a.project', ':project')
$qb->expr()->eq('a.project', ':project'),
$qb->expr()->isNull('a.project')
)
); );
if (!$query->isExcludeGlobals()) {
$orX->add($qb->expr()->isNull('a.project'));
}
$where->add($orX);
$qb->setParameter('project', $query->getProject()); $qb->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) { } elseif (null !== $query->getCustomer()) {
$where->add('p.customer = :customer'); $where->add('p.customer = :customer');
@@ -343,6 +346,11 @@ class ActivityRepository extends EntityRepository
} }
} }
// this will make sure, that we do not accidentally create results with multiple rows
// => which would result in a wrong LIMIT / pagination results
// the second group by is needed due to SQL standard (even though logically not really required for this query)
$qb->addGroupBy('a.id')->addGroupBy($orderBy);
return $qb; return $qb;
} }
@@ -352,6 +360,7 @@ class ActivityRepository extends EntityRepository
$qb $qb
->resetDQLPart('select') ->resetDQLPart('select')
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('a.id')) ->select($qb->expr()->countDistinct('a.id'))
; ;

View File

@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
@@ -208,9 +209,13 @@ class CustomerRepository extends EntityRepository
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('c') $qb
->select('c')
->from(Customer::class, 'c') ->from(Customer::class, 'c')
->orderBy('c.' . $query->getOrderBy(), $query->getOrder()); ;
$orderBy = 'c.' . $query->getOrderBy();
$qb->orderBy($orderBy, $query->getOrder());
if (CustomerQuery::SHOW_VISIBLE == $query->getVisibility()) { if (CustomerQuery::SHOW_VISIBLE == $query->getVisibility()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible')); $qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
@@ -260,6 +265,11 @@ class CustomerRepository extends EntityRepository
} }
} }
// this will make sure, that we do not accidentally create results with multiple rows
// => which would result in a wrong LIMIT / pagination results
// the second group by is needed due to SQL standard (even though logically not really required for this query)
$qb->addGroupBy('c.id')->addGroupBy($orderBy);
return $qb; return $qb;
} }
@@ -278,6 +288,7 @@ class CustomerRepository extends EntityRepository
$qb $qb
->resetDQLPart('select') ->resetDQLPart('select')
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('c.id')) ->select($qb->expr()->countDistinct('c.id'))
; ;
@@ -336,4 +347,33 @@ class CustomerRepository extends EntityRepository
throw $ex; throw $ex;
} }
} }
public function getComments(Customer $customer): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('comments')
->from(CustomerComment::class, 'comments')
->andWhere($qb->expr()->eq('comments.customer', ':customer'))
->addOrderBy('comments.pinned', 'DESC')
->addOrderBy('comments.createdAt', 'DESC')
->setParameter('customer', $customer)
;
return $qb->getQuery()->getResult();
}
public function saveComment(CustomerComment $comment)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($comment);
$entityManager->flush();
}
public function deleteComment(CustomerComment $comment)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($comment);
$entityManager->flush();
}
} }

View File

@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Model\ProjectStatistic; use App\Model\ProjectStatistic;
@@ -351,6 +352,11 @@ class ProjectRepository extends EntityRepository
} }
} }
// this will make sure, that we do not accidentally create results with multiple rows
// => which would result in a wrong LIMIT / pagination results
// the second group by is needed due to SQL standard (even though logically not really required for this query)
$qb->addGroupBy('p.id')->addGroupBy($orderBy);
return $qb; return $qb;
} }
@@ -360,6 +366,7 @@ class ProjectRepository extends EntityRepository
$qb $qb
->resetDQLPart('select') ->resetDQLPart('select')
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('p.id')) ->select($qb->expr()->countDistinct('p.id'))
; ;
@@ -438,4 +445,33 @@ class ProjectRepository extends EntityRepository
throw $ex; throw $ex;
} }
} }
public function getComments(Project $project): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('comments')
->from(ProjectComment::class, 'comments')
->andWhere($qb->expr()->eq('comments.project', ':project'))
->addOrderBy('comments.pinned', 'DESC')
->addOrderBy('comments.createdAt', 'DESC')
->setParameter('project', $project)
;
return $qb->getQuery()->getResult();
}
public function saveComment(ProjectComment $comment)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($comment);
$entityManager->flush();
}
public function deleteComment(ProjectComment $comment)
{
$entityManager = $this->getEntityManager();
$entityManager->remove($comment);
$entityManager->flush();
}
} }

View File

@@ -26,6 +26,10 @@ class ActivityQuery extends ProjectQuery
* @var bool * @var bool
*/ */
private $globalsOnly = false; private $globalsOnly = false;
/**
* @var bool
*/
private $excludeGlobals = false;
public function __construct() public function __construct()
{ {
@@ -54,6 +58,18 @@ class ActivityQuery extends ProjectQuery
return $this; return $this;
} }
public function isExcludeGlobals(): bool
{
return (bool) $this->excludeGlobals;
}
public function setExcludeGlobals(bool $excludeGlobals): self
{
$this->excludeGlobals = (bool) $excludeGlobals;
return $this;
}
/** /**
* @return Project|int|null * @return Project|int|null
*/ */

View File

@@ -82,6 +82,7 @@ class DateExtensions extends AbstractExtension
/** /**
* @param DateTime|string $date * @param DateTime|string $date
* @return string * @return string
* @throws \Exception
*/ */
public function dateShort($date) public function dateShort($date)
{ {
@@ -99,6 +100,7 @@ class DateExtensions extends AbstractExtension
/** /**
* @param DateTime|string $date * @param DateTime|string $date
* @return string * @return string
* @throws \Exception
*/ */
public function dateTime($date) public function dateTime($date)
{ {
@@ -110,14 +112,16 @@ class DateExtensions extends AbstractExtension
$date = new DateTime($date); $date = new DateTime($date);
} }
return date_format($date, $this->dateTimeFormat); return $date->format($this->dateTimeFormat);
} }
/** /**
* @param DateTime|string $date * @param DateTime|string $date
* @return string * @param bool $userTimezone
* @return bool|false|string
* @throws \Exception
*/ */
public function dateTimeFull($date) public function dateTimeFull($date, bool $userTimezone = true)
{ {
if (null === $this->dateTimeTypeFormat) { if (null === $this->dateTimeTypeFormat) {
$this->dateTimeTypeFormat = $this->localeSettings->getDateTimeTypeFormat(); $this->dateTimeTypeFormat = $this->localeSettings->getDateTimeTypeFormat();
@@ -127,11 +131,17 @@ class DateExtensions extends AbstractExtension
$date = new DateTime($date); $date = new DateTime($date);
} }
$timezone = date_default_timezone_get();
if (!$userTimezone) {
$timezone = $date->getTimezone()->getName();
}
$formatter = new \IntlDateFormatter( $formatter = new \IntlDateFormatter(
$this->localeSettings->getLocale(), $this->localeSettings->getLocale(),
\IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM,
\IntlDateFormatter::MEDIUM, \IntlDateFormatter::MEDIUM,
date_default_timezone_get(), $timezone,
\IntlDateFormatter::GREGORIAN, \IntlDateFormatter::GREGORIAN,
$this->dateTimeTypeFormat $this->dateTimeTypeFormat
); );
@@ -143,6 +153,7 @@ class DateExtensions extends AbstractExtension
* @param DateTime|string $date * @param DateTime|string $date
* @param string $format * @param string $format
* @return false|string * @return false|string
* @throws \Exception
*/ */
public function dateFormat($date, string $format) public function dateFormat($date, string $format)
{ {
@@ -156,6 +167,7 @@ class DateExtensions extends AbstractExtension
/** /**
* @param DateTime|string $date * @param DateTime|string $date
* @return string * @return string
* @throws \Exception
*/ */
public function time($date) public function time($date)
{ {

View File

@@ -40,12 +40,14 @@ final class IconExtension extends AbstractExtension
'edit' => 'far fa-edit', 'edit' => 'far fa-edit',
'end' => 'fas fa-stopwatch', 'end' => 'fas fa-stopwatch',
'export' => 'fas fa-file-export', 'export' => 'fas fa-file-export',
'fax' => 'fas fa-fax',
'filter' => 'fas fa-filter', 'filter' => 'fas fa-filter',
'help' => 'far fa-question-circle', 'help' => 'far fa-question-circle',
'home' => 'fas fa-home', 'home' => 'fas fa-home',
'invoice' => 'fas fa-file-invoice-dollar', 'invoice' => 'fas fa-file-invoice-dollar',
'invoice-template' => 'fas fa-file-signature', 'invoice-template' => 'fas fa-file-signature',
'list' => 'fas fa-list', 'list' => 'fas fa-list',
'locked' => 'fas fa-lock',
'logout' => 'fas fa-sign-out-alt', 'logout' => 'fas fa-sign-out-alt',
'mail' => 'fas fa-envelope-open', 'mail' => 'fas fa-envelope-open',
'mail-sent' => 'fas fa-paper-plane', 'mail-sent' => 'fas fa-paper-plane',
@@ -55,6 +57,7 @@ final class IconExtension extends AbstractExtension
'ods' => 'fas fa-table', 'ods' => 'fas fa-table',
'off' => 'fas fa-toggle-off', 'off' => 'fas fa-toggle-off',
'on' => 'fas fa-toggle-on', 'on' => 'fas fa-toggle-on',
'pin' => 'fas fa-thumbtack',
'pdf' => 'fas fa-file-pdf', 'pdf' => 'fas fa-file-pdf',
'pause' => 'fas fa-pause', 'pause' => 'fas fa-pause',
'pause-small' => 'far fa-pause-circle', 'pause-small' => 'far fa-pause-circle',

View File

@@ -15,9 +15,9 @@ use Twig\Extension\AbstractExtension;
use Twig\TwigFilter; use Twig\TwigFilter;
/** /**
* A twig extension to handle markdown parser. * A twig extension to handle markdown content.
*/ */
class MarkdownExtension extends AbstractExtension final class MarkdownExtension extends AbstractExtension
{ {
/** /**
* @var Markdown * @var Markdown
@@ -26,10 +26,9 @@ class MarkdownExtension extends AbstractExtension
/** /**
* @var TimesheetConfiguration * @var TimesheetConfiguration
*/ */
protected $configuration; private $configuration;
/** /**
* MarkdownExtension constructor.
* @param Markdown $parser * @param Markdown $parser
*/ */
public function __construct(Markdown $parser, TimesheetConfiguration $configuration) public function __construct(Markdown $parser, TimesheetConfiguration $configuration)
@@ -46,10 +45,36 @@ class MarkdownExtension extends AbstractExtension
return [ return [
new TwigFilter('md2html', [$this, 'markdownToHtml'], ['is_safe' => ['html']]), new TwigFilter('md2html', [$this, 'markdownToHtml'], ['is_safe' => ['html']]),
new TwigFilter('desc2html', [$this, 'timesheetContent'], ['is_safe' => ['html']]), new TwigFilter('desc2html', [$this, 'timesheetContent'], ['is_safe' => ['html']]),
new TwigFilter('comment2html', [$this, 'timesheetContent'], ['is_safe' => ['html']]), new TwigFilter('comment2html', [$this, 'commentContent'], ['is_safe' => ['html']]),
]; ];
} }
/**
* Transforms the entities comment (customer, project, activity ...) into HTML.
*
* @param string $content
* @param bool $fullLength
* @return string
*/
public function commentContent(?string $content, bool $fullLength = false): string
{
if (empty($content)) {
return '';
}
if (!$fullLength && strlen($content) > 101) {
$content = trim(substr($content, 0, 100)) . ' &hellip;';
}
if ($this->configuration->isMarkdownEnabled()) {
$content = $this->markdown->toHtml($content, false);
} elseif ($fullLength) {
$content = '<p>' . nl2br($content) . '</p>';
}
return $content;
}
/** /**
* Transforms the timesheet description content into HTML. * Transforms the timesheet description content into HTML.
* *

View File

@@ -12,19 +12,18 @@ namespace App\Utils;
/** /**
* A simple class to parse markdown syntax and return HTML. * A simple class to parse markdown syntax and return HTML.
*/ */
class Markdown final class Markdown
{ {
/** /**
* @var ParsedownExtension * @var ParsedownExtension
*/ */
private $parser; private $parser;
/**
* Markdown constructor.
*/
public function __construct() public function __construct()
{ {
$this->parser = new ParsedownExtension(); $this->parser = new ParsedownExtension();
$this->parser->setUrlsLinked(true);
$this->parser->setBreaksEnabled(true);
} }
/** /**

View File

@@ -93,8 +93,12 @@ class ActivityVoter extends AbstractVoter
} }
} }
if (null === ($customer = $project->getCustomer())) {
return false;
}
/** @var Team $team */ /** @var Team $team */
foreach ($project->getCustomer()->getTeams() as $team) { foreach ($customer->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) { if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true; return true;
} }

View File

@@ -15,25 +15,22 @@ use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/** /**
* A voter to check permissions on Customers. * A voter to check authorization on Customers.
*/ */
class CustomerVoter extends AbstractVoter class CustomerVoter extends AbstractVoter
{ {
public const VIEW = 'view';
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/** /**
* support rules based on the given $subject (here: Customer) * supported attributes/rules based on the given customer
*/ */
public const ALLOWED_ATTRIBUTES = [ public const ALLOWED_ATTRIBUTES = [
self::VIEW, 'view',
self::EDIT, 'create',
self::BUDGET, 'edit',
self::DELETE, 'budget',
self::PERMISSIONS, 'delete',
'permissions',
'comments',
'details',
]; ];
/** /**
@@ -72,6 +69,11 @@ class CustomerVoter extends AbstractVoter
return true; return true;
} }
// those cannot be assigned to teams
if (in_array($attribute, ['create', 'delete'])) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_customer'); $hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_customer');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_customer'); $hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_customer');

View File

@@ -19,21 +19,17 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
*/ */
class ProjectVoter extends AbstractVoter class ProjectVoter extends AbstractVoter
{ {
public const VIEW = 'view';
public const EDIT = 'edit';
public const BUDGET = 'budget';
public const DELETE = 'delete';
public const PERMISSIONS = 'permissions';
/** /**
* support rules based on the given $subject (here: Project) * support rules based on the given project
*/ */
public const ALLOWED_ATTRIBUTES = [ public const ALLOWED_ATTRIBUTES = [
self::VIEW, 'view',
self::EDIT, 'edit',
self::BUDGET, 'budget',
self::DELETE, 'delete',
self::PERMISSIONS, 'permissions',
'comments',
'details',
]; ];
/** /**
@@ -72,6 +68,11 @@ class ProjectVoter extends AbstractVoter
return true; return true;
} }
// those cannot be assigned to teams
if (in_array($attribute, ['create', 'delete'])) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project'); $hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project'); $hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project');

View File

@@ -38,7 +38,7 @@ class UserTeamProjects extends SimpleWidget implements AuthorizedWidget
$options = parent::getOptions($options); $options = parent::getOptions($options);
if (empty($options['id'])) { if (empty($options['id'])) {
$options['id'] = uniqid('UserTeamProjects_'); $options['id'] = 'WidgetUserTeamProjects';
} }
return $options; return $options;

View File

@@ -17,7 +17,7 @@ class UserTeams extends SimpleWidget implements AuthorizedWidget
public function __construct(CurrentUser $user) public function __construct(CurrentUser $user)
{ {
$this->setId('UserTeams'); $this->setId('UserTeams');
$this->setTitle('label.teams'); $this->setTitle('label.my_teams');
$this->setOptions([ $this->setOptions([
'user' => $user->getUser(), 'user' => $user->getUser(),
'id' => '', 'id' => '',
@@ -29,7 +29,7 @@ class UserTeams extends SimpleWidget implements AuthorizedWidget
$options = parent::getOptions($options); $options = parent::getOptions($options);
if (empty($options['id'])) { if (empty($options['id'])) {
$options['id'] = uniqid('UserTeams_'); $options['id'] = 'WidgetUserTeams';
} }
return $options; return $options;

View File

@@ -201,9 +201,6 @@
"jdorn/sql-formatter": { "jdorn/sql-formatter": {
"version": "v1.2.17" "version": "v1.2.17"
}, },
"jean85/pretty-package-versions": {
"version": "1.2"
},
"jms/metadata": { "jms/metadata": {
"version": "1.6.0" "version": "1.6.0"
}, },
@@ -267,30 +264,6 @@
"nesbot/carbon": { "nesbot/carbon": {
"version": "2.24.0" "version": "2.24.0"
}, },
"nette/bootstrap": {
"version": "v3.0.0"
},
"nette/di": {
"version": "v3.0.0"
},
"nette/finder": {
"version": "v2.5.0"
},
"nette/neon": {
"version": "v3.0.0"
},
"nette/php-generator": {
"version": "v3.2.2"
},
"nette/robot-loader": {
"version": "v3.2.0"
},
"nette/schema": {
"version": "v1.0.0"
},
"nette/utils": {
"version": "v3.0.1"
},
"nikic/php-parser": { "nikic/php-parser": {
"version": "v4.0.2" "version": "v4.0.2"
}, },
@@ -342,9 +315,6 @@
"phpspec/prophecy": { "phpspec/prophecy": {
"version": "1.7.3" "version": "1.7.3"
}, },
"phpstan/phpdoc-parser": {
"version": "0.3.3"
},
"phpstan/phpstan": { "phpstan/phpstan": {
"version": "0.11.7" "version": "0.11.7"
}, },

View File

@@ -28,6 +28,9 @@
{% if is_granted('budget', activity) %} {% if is_granted('budget', activity) %}
{% set actions = actions|merge({'report': {'url': path('admin_activity_budget', {'id': activity.id})}}) %} {% set actions = actions|merge({'report': {'url': path('admin_activity_budget', {'id': activity.id})}}) %}
{% endif %} {% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% if is_granted('view_other_timesheet') %} {% if is_granted('view_other_timesheet') %}
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customer': activity.project ? activity.project.customer.id : null, 'project': activity.project ? activity.project.id : null, 'activity': activity.id})}) %} {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'customer': activity.project ? activity.project.customer.id : null, 'project': activity.project ? activity.project.id : null, 'activity': activity.id})}) %}
{% endif %} {% endif %}
@@ -39,12 +42,12 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if view != 'index' %} {% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_activity')}) %} {% set actions = actions|merge({'back': path('admin_activity')}) %}
{% endif %} {% endif %}
{% set event = trigger('actions.activity', {'actions': actions, 'view': view, 'activity': activity}) %} {% set event = trigger('actions.activity', {'actions': actions, 'view': view, 'activity': activity}) %}
{% if view == 'index' %} {% if view == 'index' or view == 'custom' %}
{{ widgets.table_actions(event.payload.actions) }} {{ widgets.table_actions(event.payload.actions) }}
{% else %} {% else %}
{{ widgets.entity_actions(event.payload.actions) }} {{ widgets.entity_actions(event.payload.actions) }}

View File

@@ -4,19 +4,23 @@
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% import "activity/actions.html.twig" as actions %} {% import "activity/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'customer': 'hidden-xs',
'project': 'hidden-xs', 'project': 'hidden-xs',
'comment': 'hidden-xs hidden-sm', 'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
} %} } %}
{% for field in metaColumns %} {% for field in metaColumns %}
{% set columns = columns|merge({ {% set columns = columns|merge({
('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false} ('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %} }) %}
{% endfor %} {% endfor %}
{% set columns = columns|merge({ {% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false}, 'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
}) %} }) %}
@@ -40,16 +44,16 @@
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_activity_edit', {'id': entry.id}) }}"{% endif %}> <tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_activity_edit', {'id': entry.id}) }}"{% endif %}>
<td>{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td> <td>{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{% if entry.project and entry.project.customer %}
{# only none-global activities have a project and customer assigned #}
{{ widgets.label_customer(entry.project.customer) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{% if entry.project %}
{# only none-global activities have a project and customer assigned #} {# only none-global activities have a project and customer assigned #}
{% if entry.project %}
{{ widgets.label_project(entry.project) }} {{ widgets.label_project(entry.project) }}
{% if entry.project.customer %}
<br>
<small>
{{ widgets.label_customer(entry.project.customer) }}
</small>
{% endif %}
{% endif %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>
@@ -58,7 +62,9 @@
{{ tables.datatable_meta_column(entry, field) }} {{ tables.datatable_meta_column(entry, field) }}
</td> </td>
{% endfor %} {% endfor %}
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions"> <td class="actions">
{{ actions.activity(entry, 'index') }} {{ actions.activity(entry, 'index') }}
</td> </td>

View File

@@ -198,7 +198,7 @@
{% block javascripts %} {% block javascripts %}
{# no call to parent(), as we use a custom built for the frontend assets and don't want the default <script> #} {# no call to parent(), as we use a custom built for the frontend assets and don't want the default <script> #}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() { window.addEventListener('load', function() {
var loader = new KimaiWebLoader( var loader = new KimaiWebLoader(
{ {
login: '{{ path('fos_user_security_login') }}', login: '{{ path('fos_user_security_login') }}',

View File

@@ -18,6 +18,9 @@
{% set actions = {} %} {% set actions = {} %}
{% if customer.id is not empty %} {% if customer.id is not empty %}
{% if view != 'details' and is_granted('view', customer) %}
{% set actions = actions|merge({'details': path('customer_details', {'id': customer.id})}) %}
{% endif %}
{% if is_granted('edit', customer) %} {% if is_granted('edit', customer) %}
{% set class = '' %} {% set class = '' %}
{% if view != 'edit' %} {% if view != 'edit' %}
@@ -25,13 +28,17 @@
{% endif %} {% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_customer_edit', {'id': customer.id}), 'class': class}}) %} {% set actions = actions|merge({'edit': {'url': path('admin_customer_edit', {'id': customer.id}), 'class': class}}) %}
{% endif %} {% endif %}
{% if is_granted('budget', customer) %}
{% set actions = actions|merge({'report': {'url': path('admin_customer_budget', {'id': customer.id})}}) %}
{% endif %}
{% if is_granted('permissions', customer) %} {% if is_granted('permissions', customer) %}
{% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id})}}) %} {% set class = '' %}
{% if view != 'permissions' %}
{% set class = 'modal-ajax-form' %}
{% endif %} {% endif %}
{% if is_granted('view_project') %} {% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id}), 'class': class}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% if is_granted('view_project') or is_granted('view_teamlead_project') or is_granted('view_team_project') %}
{% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %} {% set actions = actions|merge({'project': path('admin_project', {'customer': customer.id})}) %}
{% endif %} {% endif %}
{% if is_granted('view_activity') %} {% if is_granted('view_activity') %}
@@ -48,12 +55,12 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if view != 'index' %} {% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_customer')}) %} {% set actions = actions|merge({'back': path('admin_customer')}) %}
{% endif %} {% endif %}
{% set event = trigger('actions.customer', {'actions': actions, 'view': view, 'customer': customer}) %} {% set event = trigger('actions.customer', {'actions': actions, 'view': view, 'customer': customer}) %}
{% if view == 'index' %} {% if view == 'index' or view == 'custom' %}
{{ widgets.table_actions(event.payload.actions) }} {{ widgets.table_actions(event.payload.actions) }}
{% else %} {% else %}
{{ widgets.entity_actions(event.payload.actions) }} {{ widgets.entity_actions(event.payload.actions) }}

View File

@@ -1,31 +0,0 @@
{% extends 'base.html.twig' %}
{% import "customer/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.customer(customer, 'delete') }}{% endblock %}
{% block main %}
{% set params = {
'%activity%': '<strong>' ~ stats.activityAmount ~ '</strong>',
'%project%': '<strong>' ~ stats.projectAmount ~ '</strong>',
'%customer%': '<strong>' ~ customer.name ~ '</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>',
'%rate%': '<strong>' ~ stats.recordRate|money ~ '</strong>'
} %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ customer.name }}{% endblock %}
{% block box_body %}
<p>
{{ 'admin_customer.short_stats'|trans(params)|raw }}
</p>
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,174 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as actions %}
{% block page_actions %}{{ actions.customer(customer, 'details') }}{% endblock %}
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
{% block main %}
{% set can_edit = is_granted('edit', customer) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="customer_details_box"{% endblock %}
{% block box_title %}
{{ widgets.label_customer(customer) }}
{% if customer.company is not empty %} &ndash; {{ customer.company }}{% endif %}
{% endblock %}
{% block box_tools %}
{% if can_edit %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_customer_edit', {'id': customer.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'action.edit'|trans }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_body %}
{% if customer.comment is not empty %}
{{ customer.comment|comment2html(true) }}
{% endif %}
<table class="table table-hover">
{% if not customer.visible %}
<tr>
<th>{{ 'label.visible'|trans }}</th>
<td>
{{ widgets.label_boolean(customer.visible) }}
</td>
</tr>
{% endif %}
{% if customer.address is not empty %}
<tr>
<th>{{ 'label.address'|trans }}</th>
<td>
{{ customer.address|nl2br }}
{% if customer.country is not empty %}
<br>
{{ customer.country|country }}
{% endif %}
</td>
</tr>
{% endif %}
{% if customer.contact is not empty %}
<tr>
<th>{{ 'label.contact'|trans }}</th>
<td>{{ customer.contact }}</td>
</tr>
{% endif %}
{% if customer.phone is not empty %}
<tr>
<th>{{ 'label.phone'|trans }}</th>
<td><a href="tel:{{ customer.phone }}">{{ customer.phone }}</a></td>
</tr>
{% endif %}
{% if customer.mobile is not empty %}
<tr>
<th>{{ 'label.mobile'|trans }}</th>
<td><a href="tel:{{ customer.mobile }}">{{ customer.mobile }}</a></td>
</tr>
{% endif %}
{% if customer.email is not empty %}
<tr>
<th>{{ 'label.email'|trans }}</th>
<td><a href="mailto:{{ customer.email }}">{{ customer.email }}</a></td>
</tr>
{% endif %}
{% if customer.homepage is not empty %}
<tr>
<th>{{ 'label.homepage'|trans }}</th>
<td><a href="{{ customer.homepage }}" target="_blank">{{ customer.homepage|replace({'https://': '', 'http://': ''}) }}</a></td>
</tr>
{% endif %}
{% if customer.fax is not empty %}
<tr>
<th>{{ 'label.fax'|trans }}</th>
<td>{{ customer.fax }}</td>
</tr>
{% endif %}
{% if customer.timezone is not empty %}
<tr>
<th>{{ 'label.timezone'|trans }}</th>
<td><span data-toggle="tooltip" data-placement="top" title="{{ customer.timezone }}">{{ now|date_full(false) }}</span></td>
</tr>
{% endif %}
{% if customer.currency is not empty %}
<tr>
<th>{{ 'label.currency'|trans }}</th>
<td>{{ customer.currency }}</td>
</tr>
{% endif %}
{% if is_granted('details', customer) %}
{% if customer.number is not empty %}
<tr>
<th>{{ 'label.number'|trans }}</th>
<td>{{ customer.number }}</td>
</tr>
{% endif %}
{% if customer.vatId is not empty %}
<tr>
<th>{{ 'label.vat_id'|trans }}</th>
<td>{{ customer.vatId }}</td>
</tr>
{% endif %}
{% if can_edit %}
<tr>
<th>{{ 'label.fixedRate'|trans }}</th>
<td>
{% if customer.fixedRate is not empty %}
{{ customer.fixedRate|money(customer.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
<tr>
<th>{{ 'label.hourlyRate'|trans }}</th>
<td>
{% if customer.hourlyRate is not empty %}
{{ customer.hourlyRate|money(customer.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
{% endif %}
{% for metaField in customer.visibleMetaFields %}
<tr>
<th>{{ metaField.name }}</th>
<td>{{ widgets.form_type_value(metaField.type, metaField.value, customer) }}</td>
</tr>
{% endfor %}
{% endif %}
</table>
{% endblock %}
{% endembed %}
{{ render(controller('App\\Controller\\CustomerController::projectsAction', {'customer': customer.id, 'page': 1})) }}
{% if stats is not null %}
{{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }}
{% endif %}
{% if teams is not null %}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', customer) %}
{% set options = options|merge({'route_create': path('customer_team_create', {'id': customer.id}), 'route_edit': path('admin_customer_permissions', {'id': customer.id})}) %}
{% endif %}
{{ include('embeds/teams.html.twig', options) }}
{% endif %}
{% if comments is not null %}
{% set options = {'form': commentForm, 'comments': comments} %}
{% if can_edit %}
{% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete'}) %}
{% endif %}
{{ include('embeds/comments.html.twig', options) }}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate');
});
</script>
{% endblock %}

View File

@@ -23,17 +23,18 @@
</div> </div>
</div> </div>
{{ form_row(form.comment) }} {{ form_row(form.comment) }}
<fieldset> {{ form_row(form.address) }}
{#<legend>Company data</legend>#}
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-4">
{{ form_row(form.company) }} {{ form_row(form.company) }}
</div> </div>
<div class="col-md-4">
{{ form_row(form.number) }}
</div>
<div class="col-md-4"> <div class="col-md-4">
{{ form_row(form.vatId) }} {{ form_row(form.vatId) }}
</div> </div>
</div> </div>
{{ form_row(form.address) }}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
{{ form_row(form.country) }} {{ form_row(form.country) }}
@@ -45,9 +46,6 @@
{{ form_row(form.timezone) }} {{ form_row(form.timezone) }}
</div> </div>
</div> </div>
</fieldset>
<fieldset>
{#<legend>Contact data</legend>#}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
{{ form_row(form.contact) }} {{ form_row(form.contact) }}
@@ -70,9 +68,6 @@
{{ form_row(form.fax) }} {{ form_row(form.fax) }}
</div> </div>
</div> </div>
</fieldset>
<fieldset>
{#<legend>Financial data</legend>#}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
{{ form_row(form.fixedRate) }} {{ form_row(form.fixedRate) }}
@@ -91,13 +86,9 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</fieldset>
{{ form_row(form.visible) }} {{ form_row(form.visible) }}
{% if form.metaFields is defined and form.metaFields is not empty %} {% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset>
{#<legend>Custom fields</legend>#}
{{ form_row(form.metaFields) }} {{ form_row(form.metaFields) }}
</fieldset>
{% endif %} {% endif %}
{{ form_widget(form) }} {{ form_widget(form) }}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,22 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'customer': customer, 'stats': stats} %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set params = {
'%activity%': '<strong>' ~ stats.activityAmount ~ '</strong>',
'%project%': '<strong>' ~ stats.projectAmount ~ '</strong>',
'%customer%': '<strong>' ~ customer.name ~ '</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>',
'%rate%': '<strong>' ~ stats.recordRate|money ~ '</strong>'
} %}
<p>
{{ 'admin_customer.short_stats'|trans(params)|raw }}
</p>
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(customer.currency) ~ ' / ' ~ customer.budget|money(customer.currency) ) }}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -0,0 +1,30 @@
{% if projects|length > 0 %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'customer': customer, 'projects': projects, 'page': page} %}
{% import "project/actions.html.twig" as actions %}
{% block box_title %}{{ 'menu.admin_project'|trans }}{% endblock %}
{% block box_attributes %}
id="project_list_box" data-href="{{ path('customer_projects', {'id': customer.id}) }}" data-reload="kimai.projectUpdate"
{% endblock %}
{% block box_tools %}
{{ pagerfanta(projects, 'twitter_bootstrap3_translated', { proximity: 1, css_container_class: 'pagination pagination-sm inline', routeName: 'customer_projects', routeParams: {'id': customer.id} }) }}
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_tools_attributes %}data-page="{{ page }}"{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable">
<tbody>
{% for project in projects %}
<tr>
<td>{{ project.name }}</td>
<td>{{ project.comment|comment2html }}</td>
<td class="actions">{{ actions.project(project, 'custom') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endembed %}
<script type="text/javascript">
KimaiPaginatedBoxWidget.create('#project_list_box');
</script>
{% endif %}

View File

@@ -4,11 +4,10 @@
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% import "customer/actions.html.twig" as actions %} {% import "customer/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'comment': 'hidden-xs', 'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
'country': 'hidden-xs hidden-sm',
'number': 'hidden-xs',
} %} } %}
{% for field in metaColumns %} {% for field in metaColumns %}
{% set columns = columns|merge({ {% set columns = columns|merge({
@@ -16,12 +15,14 @@
}) %} }) %}
{% endfor %} {% endfor %}
{% set columns = columns|merge({ {% set columns = columns|merge({
'email': {'class': 'text-center hidden hidden-xs', 'orderBy': false},
'homepage': {'class': 'text-center hidden hidden-xs', 'orderBy': false},
'mobile': {'class': 'text-center hidden hidden-xs', 'orderBy': false},
'phone': {'class': 'text-center hidden hidden-xs', 'orderBy': false},
'team': {'class': 'text-center', 'orderBy': false}, 'team': {'class': 'text-center', 'orderBy': false},
}) %}
{% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false}, 'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
}) %} }) %}
@@ -40,47 +41,21 @@
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %} {% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerTeamUpdate'}) }}
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_customer_edit', {'id': entry.id}) }}"{% endif %}> <tr class="alternative-link open-edit" data-href="{{ path('customer_details', {'id': entry.id}) }}">
<td>{{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }} {% if entry.company is not empty %}({{ entry.company }}){% endif %}</td> <td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> {{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }}
<td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">
{{ entry.comment|comment2html }}
</td>
{% for field in metaColumns %} {% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }} {{ tables.datatable_meta_column(entry, field) }}
</td> </td>
{% endfor %} {% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">
{% if entry.email is not empty %}
<a href="mailto:{{ entry.email }}"><i class="{{ 'mail'|icon }}"></i></a>
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'homepage') }}">
{% if entry.homepage is not empty %}
<a href="{{ entry.homepage }}" target="_blank"><i class="{{ 'home'|icon }}"></i></a>
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'mobile') }}">
{% if entry.mobile is not empty %}
<a href="tel:{{ entry.mobile }}"><i class="{{ 'mobile'|icon }}"></i></a>
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'phone') }}">
{% if entry.phone is not empty %}
<a href="tel:{{ entry.phone }}"><i class="{{ 'phone'|icon }}"></i></a>
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% if entry.teams|length > 0 %} {% if entry.teams|length > 0 %}
{{ widgets.badge_counter(entry.teams|length) }} {{ widgets.badge_counter(entry.teams|length) }}
@@ -88,7 +63,9 @@
{{ widgets.icon('unlocked') }} {{ widgets.icon('unlocked') }}
{% endif %} {% endif %}
</td> </td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions"> <td class="actions">
{{ actions.customer(entry, 'index') }} {{ actions.customer(entry, 'index') }}
</td> </td>

View File

@@ -1,21 +1,30 @@
<div class="box box-solid box-danger"> {% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'boxtype': 'danger'} %}
<div class="box-header"> {% block box_before %}
<h3 class="box-title">{{ title|default('confirm.delete'|trans) }}</h3>
</div>
<div class="box-body">
{{ message|raw }}
</div>
{{ form_start(form) }} {{ form_start(form) }}
<div class="box-body">
{% block form_body %}
{{ form_widget(form) }}
{% endblock %} {% endblock %}
{% block box_title %}
{{ title|default('confirm.delete'|trans) }}
{% endblock %}
{% block box_body %}
{% block form_body %}
{% if used is same as (false) %}
{{ 'delete.not_in_use'|trans }}
<div class="hidden">
{{ form_widget(form) }}
</div> </div>
<div class="box-footer"> {% else %}
<p>{{ message|raw }}</p>
{{ form_widget(form) }}
{% endif %}
{% endblock %}
{% endblock %}
{% block box_footer %}
<input type="submit" value="{{ 'action.delete'|trans }}" class="btn btn-danger" /> <input type="submit" value="{{ 'action.delete'|trans }}" class="btn btn-danger" />
{% if back %} {% if back %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a> <a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %} {% endif %}
</div> {% endblock %}
{% block box_after %}
{{ form_end(form) }} {{ form_end(form) }}
</div> {% endblock %}
{% endembed %}

View File

@@ -0,0 +1,53 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null)} %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'label.comment'|trans }}{% endblock %}
{% block box_attributes %}id="comments_box"{% endblock %}
{% block box_body_class %}box-comments{% endblock %}
{% block box_body %}
{% set replacer = {} %}
{% for pref in app.user.preferences %}
{% set replacer = replacer|merge({('{{user.' ~ pref.name ~ '}}'): pref.value}) %}
{% endfor %}
{% if comments|length == 0 %}
{{ 'error.no_comments_found'|trans }}
{% endif %}
{% for comment in comments %}
<div class="box-comment">
{{ widgets.user_avatar(comment.createdBy, false, 'img-sm') }}
<div class="comment-text">
<span class="username">
{{ widgets.username(comment.createdBy) }}
<span class="text-muted pull-right">
{% if route_pin is not null %}
<a href="{{ path(route_pin, {'id': comment.id}) }}" class="btn btn-default btn-xs {% if comment.pinned %}active{% endif %}"><i class="{{ 'pin'|icon }}"></i></a>
{% endif %}
{% if route_delete is not null %}
<a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-method="POST" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success"><i class="{{ 'delete'|icon }}"></i></a>
{% endif %}
{{ comment.createdAt|date_full }}
</span>
</span>
{{ comment.message|replace(replacer)|md2html }}
</div>
</div>
{% endfor %}
{% endblock %}
{% block box_footer -%}
{% if form is not null %}
{{ form_start(form) }}
<div class="input-group">
{{ widgets.user_avatar(app.user, false, 'img-responsive img-sm') }}
<div class="img-push">
{{ form_widget(form.message) }}
</div>
<span class="input-group-btn">
<button type="submit" class="btn btn-default">
{{ 'action.save'|trans }}
</button>
</span>
</div>
{{ form_widget(form) }}
{{ form_end(form) }}
{% endif %}
{%- endblock %}
{% endembed %}

View File

@@ -0,0 +1,32 @@
{%
set options = {
'teams': teams,
'team': team|default(null),
'route_create': route_create|default(null),
'route_edit': route_edit|default(null),
'empty_message': empty_message|default('team.visibility_global')
}
%}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with options %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_tools %}
{% if teams|length == 0 and team is null and route_create is not null and is_granted('create_team') %}
<a class="btn-box-tool" href="{{ route_create }}" data-toggle="tooltip" data-placement="top" title="{{ 'team.create_default'|trans({}, 'teams') }}"><i class="{{ 'create'|icon }}"></i></a>
{% endif %}
{% if route_edit is not null %}
<a class="modal-ajax-form open-edit btn btn-box-tool" href="{{ route_edit }}" data-toggle="tooltip" data-placement="top" title="{{ 'action.edit'|trans }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_attributes %}id="team_listing_box"{% endblock %}
{% block box_title %}
{{ 'permissions'|trans({}, 'actions') }}
{% endblock %}
{% block box_body %}
{% if teams|length > 0 %}
<p>{{ 'team.visibility_restricted'|trans({}, 'teams') }}</p>
{{ widgets.team_list(teams) }}
{% else %}
<p>{{ empty_message|default('team.visibility_global')|trans({}, 'teams') }}</p>
{% endif %}
{% endblock %}
{% endembed %}

View File

@@ -15,7 +15,17 @@
{% import _self as macro %} {% import _self as macro %}
<div class="breadcrumb"> <div class="breadcrumb">
<div class="box-tools"> <div class="box-tools">
{{ macro.table_actions(tools) }} <div class="btn-group">
{% set actions = {} %}
{%- for icon, values in tools %}
{% if icon == 'back' %}
{{ macro.action_button(icon, values) }}
{% else %}
{% set actions = actions|merge({(icon): values}) %}
{% endif %}
{% endfor %}
{{ macro.table_actions(actions, '') }}
</div>
</div> </div>
</div> </div>
{%- endmacro -%} {%- endmacro -%}
@@ -69,11 +79,10 @@
{% macro user_avatar(user, tooltip, class) %} {% macro user_avatar(user, tooltip, class) %}
{% set avatar = avatar(user, admin_lte_context.default_avatar) %} {% set avatar = avatar(user, admin_lte_context.default_avatar) %}
{% set showTooltip = tooltip|default(true) %} {% if tooltip is same as (false) %}
{% if tooltip is not defined or tooltip is same as (false)%} <img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" alt="{{ user.displayName }}" />
<img src="{{ avatar }}" class="{{ class|default('img-circle') }}" alt="{{ user.displayName }}" />
{% else %} {% else %}
<img src="{{ avatar }}" class="{{ class|default('img-circle') }}" data-toggle="tooltip" data-placement="top" alt="{{ user.displayName }}" title="{{ user.displayName }}" /> <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 %} {% endif %}
{% endmacro %} {% endmacro %}
@@ -200,30 +209,50 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro table_actions(actions) %} {% macro table_actions(actions, class) %}
{%- import _self as macro -%} {%- import _self as macro -%}
{% if actions|length >= 1 %} {% if actions|length >= 1 %}
<div class="btn-group"> {% if class is null %}
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">{{ 'label.actions'|trans }} {% set class = 'btn-sm' %}
<span class="fa fa-caret-down"></span></button> {% endif %}
{% set trash = null %}
{% set divider = false %}
<div class="btn-group dropdown">
<button type="button" class="btn btn-default {{ class }} dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
<span class="fa fa-chevron-down"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right"> <ul class="dropdown-menu dropdown-menu-right">
{%- apply spaceless -%} {%- apply spaceless -%}
{%- for icon,values in actions %} {%- for icon,values in actions %}
{% set class = '' %} {% if icon == 'divider' and values is null %}
{% if icon == 'trash' %} {% if not loop.last and divider is same as (false) %}
{% set class = 'delete' %}
{% if actions|length > 1 %}
<li class="divider"></li> <li class="divider"></li>
{% endif %} {% endif %}
{% endif %} {% set divider = true %}
<li class="{{ class }}">
{% if values is iterable %}
{{ macro.action_button(icon, values|merge({'title': icon|trans({}, 'actions')}), false) }}
{% else %} {% else %}
{{ macro.action_button(icon, {'url': values, 'title': icon|trans({}, 'actions')}, false) }} {% if values is iterable %}
{% set values = values|merge({'title': icon|trans({}, 'actions')}) %}
{% else %}
{% set values = {'url': values, 'title': icon|trans({}, 'actions')} %}
{% endif %} {% endif %}
{% if icon == 'trash' %}
{% set trash = values %}
{% else %}
{% set divider = false %}
<li>
{{ macro.action_button(icon, values, false) }}
</li> </li>
{% endif %}
{% endif %}
{% endfor -%} {% endfor -%}
{%- if trash is not null %}
{% if actions|length > 1 and divider is same as (false) %}
<li class="divider"></li>
{% endif %}
<li class="delete">
{{ macro.action_button('trash', trash, false) }}
</li>
{% endif -%}
{% endapply %} {% endapply %}
</ul> </ul>
</div> </div>
@@ -371,3 +400,49 @@
{{ value }} {{ value }}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro team_list(teams, showTitle) %}
{% if showTitle is null %}
{% set showTitle = true %}
{% endif %}
{% import _self as macro %}
<table class="table table-hover dataTable" role="grid">
{% if showTitle %}
<thead>
<tr>
<th>{{ 'label.team'|trans }}</th>
<th>{{ 'label.user'|trans }}</th>
</tr>
</thead>
{% endif %}
<tbody>
{% for team in teams %}
<tr{% if is_granted('edit', team) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_team_member', {'id': team.id}) }}"{% endif %}>
<td>
{{ team.name }}
</td>
<td class="avatars">
{% set userTeamCount = team.users|length %}
{{ macro.user_avatar(team.teamlead, ('label.teamlead'|trans ~ ': ' ~ team.teamlead.displayName), 'teamlead') }}
{% set teamHiddenId = 'team_' ~ team.id ~ '_hiddenUser' ~ random() %}
{% set counter = 0 %}
{% for user in team.users %}
{% if user != team.teamlead %}
{{ macro.user_avatar(user) }}
{% set counter = counter + 1 %}
{% endif %}
{% if userTeamCount > 5 and counter == 4 and not loop.last %}
<a href="#" onclick="$('#{{ teamHiddenId }}').toggleClass('hidden');$(this).hide();return false;" class="badge">{{ 'label.plus_more'|trans({'%count%': (userTeamCount - 5)}) }}</a>
<span class="hidden" id="{{ teamHiddenId }}">
{% set counter = counter + 1 %}
{% endif %}
{% if userTeamCount > 5 and counter != 4 and loop.last %}
</span>
{% endif %}
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}

View File

@@ -18,6 +18,9 @@
{% set actions = {} %} {% set actions = {} %}
{% if project.id is not empty %} {% if project.id is not empty %}
{% if view != 'details' and is_granted('view', project) %}
{% set actions = actions|merge({'details': path('project_details', {'id': project.id})}) %}
{% endif %}
{% if is_granted('edit', project) %} {% if is_granted('edit', project) %}
{% set class = '' %} {% set class = '' %}
{% if view != 'edit' %} {% if view != 'edit' %}
@@ -25,11 +28,15 @@
{% endif %} {% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_project_edit', {'id': project.id}), 'class': class}}) %} {% set actions = actions|merge({'edit': {'url': path('admin_project_edit', {'id': project.id}), 'class': class}}) %}
{% endif %} {% endif %}
{% if is_granted('budget', project) %}
{% set actions = actions|merge({'report': {'url': path('admin_project_budget', {'id': project.id})}}) %}
{% endif %}
{% if is_granted('permissions', project) %} {% if is_granted('permissions', project) %}
{% set actions = actions|merge({'permissions': {'url': path('admin_project_permissions', {'id': project.id})}}) %} {% set class = '' %}
{% if view != 'permissions' %}
{% set class = 'modal-ajax-form' %}
{% endif %}
{% set actions = actions|merge({'permissions': {'url': path('admin_project_permissions', {'id': project.id}), 'class': class}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %} {% endif %}
{% if is_granted('view_activity') %} {% if is_granted('view_activity') %}
{% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %} {% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %}
@@ -45,12 +52,12 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if view != 'index' %} {% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_project')}) %} {% set actions = actions|merge({'back': path('admin_project')}) %}
{% endif %} {% endif %}
{% set event = trigger('actions.project', {'actions': actions, 'view': view, 'project': project}) %} {% set event = trigger('actions.project', {'actions': actions, 'view': view, 'project': project}) %}
{% if view == 'index' %} {% if view == 'index' or view == 'custom' %}
{{ widgets.table_actions(event.payload.actions) }} {{ widgets.table_actions(event.payload.actions) }}
{% else %} {% else %}
{{ widgets.entity_actions(event.payload.actions) }} {{ widgets.entity_actions(event.payload.actions) }}

View File

@@ -1,30 +0,0 @@
{% extends 'base.html.twig' %}
{% import "project/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.project(project, 'delete') }}{% endblock %}
{% block main %}
{% set params = {
'%project%': '<strong>' ~ project.name ~ '</strong>',
'%customer%': '<strong>' ~ project.customer.name ~ '</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%activities%': '<strong>' ~ stats.activityAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>'
} %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ project.name }}{% endblock %}
{% block box_body %}
<p>
{{ 'admin_project.short_stats'|trans(params)|raw }}
</p>
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,151 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "project/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.project(project, 'details') }}{% endblock %}
{% block main %}
{% set can_edit = is_granted('edit', project) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as customerActions %}
{% block box_attributes %}id="project_details_box"{% endblock %}
{% block box_tools %}
{% if can_edit %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_project_edit', {'id': project.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'action.edit'|trans }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_title %}
{{ widgets.label_project(project) }}
{% endblock %}
{% block box_body %}
{% if project.comment is not empty %}
{{ project.comment|comment2html(true) }}
{% endif %}
<table class="table table-hover">
{% if not project.visible %}
<tr>
<th>{{ 'label.visible'|trans }}</th>
<td>
{{ widgets.label_boolean(project.visible) }}
</td>
</tr>
{% endif %}
<tr>
<th>{{ 'label.customer'|trans }}</th>
<td>
{{ widgets.label_customer(project.customer) }}
{% if project.customer.teams|length == 0 %}
{{ widgets.icon('unlocked') }}
{% endif %}
&nbsp;
{{ customerActions.customer(project.customer, 'custom') }}
</td>
</tr>
{% if is_granted('details', project) %}
<tr>
<th>{{ 'label.orderNumber'|trans }}</th>
<td>
{{ project.orderNumber }}
</td>
</tr>
<tr>
<th>{{ 'label.orderDate'|trans }}</th>
<td>
{% if project.orderDate is not empty %}
{{ project.orderDate|date_full }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
<tr>
<th>{{ 'label.project_start'|trans }}</th>
<td>
{% if project.start is not empty %}
{{ project.start|date_full }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
<tr>
<th>{{ 'label.project_end'|trans }}</th>
<td>
{% if project.end is not empty %}
{{ project.end|date_full }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
{% if can_edit %}
<tr>
<th>{{ 'label.fixedRate'|trans }}</th>
<td>
{% if project.fixedRate is not empty %}
{{ project.fixedRate|money(project.customer.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
<tr>
<th>{{ 'label.hourlyRate'|trans }}</th>
<td>
{% if project.hourlyRate is not empty %}
{{ project.hourlyRate|money(project.customer.currency) }}
{% else %}
&ndash;
{% endif %}
</td>
</tr>
{% endif %}
{% for metaField in project.visibleMetaFields %}
<tr>
<th>{{ metaField.name }}</th>
<td>{{ widgets.form_type_value(metaField.type, metaField.value, project) }}</td>
</tr>
{% endfor %}
{% endif %}
</table>
{% endblock %}
{% endembed %}
{{ render(controller('App\\Controller\\ProjectController::activitiesAction', {'project': project.id, 'page': 1})) }}
{% if stats is not null %}
{{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }}
{% endif %}
{% if teams is not null%}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', project) %}
{% set options = options|merge({'route_create': path('project_team_create', {'id': project.id}), 'route_edit': path('admin_project_permissions', {'id': project.id})}) %}
{% endif %}
{% if project.customer.teams|length > 0 %}
{% set options = options|merge({'empty_message': 'team.project_visibility_inherited'}) %}
{% endif %}
{{ include('embeds/teams.html.twig', options) }}
{% endif %}
{% if comments is not null %}
{% set options = {'form': commentForm, 'comments': comments} %}
{% if can_edit %}
{% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete'}) %}
{% endif %}
{{ include('embeds/comments.html.twig', options) }}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.projectTeamUpdate kimai.projectUpdate kimai.teamUpdate kimai.customerUpdate ');
});
</script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% if activities|length > 0 %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'project': project, 'activities': activities, 'page': page} %}
{% import "activity/actions.html.twig" as actions %}
{% block box_title %}{{ 'menu.admin_activity'|trans }}{% endblock %}
{% block box_attributes %}
id="activity_list_box" data-href="{{ path('project_activities', {'id': project.id}) }}" data-reload="kimai.activityUpdate"
{% endblock %}
{% block box_tools %}
{{ pagerfanta(activities, 'twitter_bootstrap3_translated', { proximity: 1, css_container_class: 'pagination pagination-sm inline', routeName: 'project_activities', routeParams: {'id': project.id} }) }}
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_tools_attributes %}data-page="{{ page }}"{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable">
<tbody>
{% for activity in activities %}
<tr>
<td>{{ activity.name }}</td>
<td class="actions">{{ actions.activity(activity, 'custom') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endembed %}
<script type="text/javascript">
KimaiPaginatedBoxWidget.create('#activity_list_box');
</script>
{% endif %}

View File

@@ -0,0 +1,21 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set params = {
'%project%': '<strong>' ~ project.name ~ '</strong>',
'%customer%': '<strong>' ~ project.customer.name ~ '</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%activities%': '<strong>' ~ stats.activityAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>'
} %}
<p>
{{ 'admin_project.short_stats'|trans(params)|raw }}
</p>
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(project.customer.currency) ~ ' / ' ~ project.budget|money(project.customer.currency) ) }}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -4,12 +4,11 @@
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% import "project/actions.html.twig" as actions %} {% import "project/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = { {% set columns = {
'name': 'alwaysVisible', 'name': 'alwaysVisible',
'customer': 'hidden-xs', 'customer': 'hidden-xs',
'comment': 'hidden-xs hidden-sm', 'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
'orderNumber': 'hidden-xs hidden-sm',
'orderDate': 'hidden-xs hidden-sm',
} %} } %}
{% for field in metaColumns %} {% for field in metaColumns %}
{% set columns = columns|merge({ {% set columns = columns|merge({
@@ -18,7 +17,13 @@
{% endfor %} {% endfor %}
{% set columns = columns|merge({ {% set columns = columns|merge({
'team': {'class': 'text-center', 'orderBy': false}, 'team': {'class': 'text-center', 'orderBy': false},
}) %}
{% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false}, 'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
}) %} }) %}
@@ -37,21 +42,15 @@
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %} {% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectTeamUpdate'}) }}
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_project_edit', {'id': entry.id}) }}"{% endif %}> <tr class="alternative-link open-edit" data-href="{{ path('project_details', {'id': entry.id}) }}">
<td>{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td> <td>{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{{ widgets.label_customer(entry.customer) }} {{ widgets.label_customer(entry.customer) }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html() }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'orderNumber') }}">{{ entry.orderNumber }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'orderDate') }}">
{% if entry.orderDate is not empty %}
{{ entry.orderDate|date_short }}
{% endif %}
</td>
{% for field in metaColumns %} {% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"> <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }} {{ tables.datatable_meta_column(entry, field) }}
@@ -64,7 +63,9 @@
{{ widgets.icon('unlocked') }} {{ widgets.icon('unlocked') }}
{% endif %} {% endif %}
</td> </td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions"> <td class="actions">
{{ actions.project(entry, 'index') }} {{ actions.project(entry, 'index') }}
</td> </td>

View File

@@ -0,0 +1,14 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "team/actions.html.twig" as actions %}
{% block page_title %}{{ 'teams.title'|trans({}, 'teams') }}{% endblock %}
{% block page_actions %}{{ actions.team(team, 'edit') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': team.name|default('create'|trans),
'form': form,
'back': path('admin_team')
}) }}
{% endblock %}

View File

@@ -46,6 +46,10 @@
{% set actions = actions|merge({'edit': {'url': path('admin_timesheet_edit', {'id': timesheet.id}), 'class': class}}) %} {% set actions = actions|merge({'edit': {'url': path('admin_timesheet_edit', {'id': timesheet.id}), 'class': class}}) %}
{% endif %} {% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% if view == 'index' and is_granted('delete', timesheet) %} {% if view == 'index' and is_granted('delete', timesheet) %}
{% set actions = actions|merge({'trash': {'url': path('delete_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %} {% set actions = actions|merge({'trash': {'url': path('delete_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %}
{% endif %} {% endif %}

View File

@@ -47,6 +47,10 @@
{% set actions = actions|merge({'edit': {'url': path('timesheet_edit', {'id': timesheet.id}), 'class': class}}) %} {% set actions = actions|merge({'edit': {'url': path('timesheet_edit', {'id': timesheet.id}), 'class': class}}) %}
{% endif %} {% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% if view == 'index' and is_granted('delete', timesheet) %} {% if view == 'index' and is_granted('delete', timesheet) %}
{% set actions = actions|merge({'trash': {'url': path('delete_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %} {% set actions = actions|merge({'trash': {'url': path('delete_timesheet', {'id' : timesheet.id}), 'class': 'api-link', 'attr': {'data-event': 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method': 'DELETE', 'data-question': 'confirm.delete', 'data-msg-error': 'action.delete.error', 'data-msg-success': 'action.delete.success'}}}) %}
{% endif %} {% endif %}

View File

@@ -65,6 +65,9 @@
{% if is_granted('preferences', user) %} {% if is_granted('preferences', user) %}
{% set actions = actions|merge({'settings': {'url': path('user_profile_preferences', {'username' : user.username})}}) %} {% set actions = actions|merge({'settings': {'url': path('user_profile_preferences', {'username' : user.username})}}) %}
{% endif %} {% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% if is_granted('view_other_timesheet') and user.enabled %} {% if is_granted('view_other_timesheet') and user.enabled %}
{% set actions = actions|merge({'timesheet': path('admin_timesheet', {'users': [user.id]})}) %} {% set actions = actions|merge({'timesheet': path('admin_timesheet', {'users': [user.id]})}) %}
{% endif %} {% endif %}

View File

@@ -4,21 +4,28 @@
{% import "macros/toolbar.html.twig" as toolbar %} {% import "macros/toolbar.html.twig" as toolbar %}
{% import "user/actions.html.twig" as actions %} {% import "user/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = { {% set columns = {
'alias': 'alwaysVisible', 'alias': 'alwaysVisible',
'username': 'hidden-xs',
'email': 'hidden-xs hidden-md hidden-sm',
'title': 'hidden-xs hidden-sm', 'title': 'hidden-xs hidden-sm',
} %} } %}
{% set columns = columns|merge({
'roles': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for pref in preferences %} {% for pref in preferences %}
{% set columns = columns|merge({ {% set columns = columns|merge({
('mf_' ~ pref.name): {'title': pref.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false} ('mf_' ~ pref.name): {'title': pref.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %} }) %}
{% endfor %} {% endfor %}
{% set columns = columns|merge({ {% set columns = columns|merge({
'roles': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, 'team': {'class': 'text-center hidden-xs', 'orderBy': false},
'team': {'class': 'hidden-xs', 'orderBy': false}, }) %}
{% if showVisibility %}
{% set columns = columns|merge({
'active': {'class': '', 'orderBy': false}, 'active': {'class': '', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible', 'actions': 'actions alwaysVisible',
}) %} }) %}
@@ -40,19 +47,9 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile_edit', {'username': entry.username}) }}"{% endif %}> <tr{% if is_granted('view', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile', {'username': entry.username}) }}"{% endif %}>
<td>{{ widgets.user_avatar(entry) }} {{ widgets.username(entry) }}</td> <td>{{ widgets.user_avatar(entry) }} {{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">{{ entry.username }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'email') }}">{{ entry.email }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
{% for pref in preferences %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ pref.name) }}">
{% set metaField = entry.preference(pref.name) %}
{% if not metaField is null and metaField.value is not null and metaField.value is not empty %}
{{ widgets.form_type_value(pref.type, metaField.value, entry) }}
{% endif %}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'roles') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'roles') }}">
{% set showUserRole = entry.roles|length == 1 %} {% set showUserRole = entry.roles|length == 1 %}
{% for role in entry.roles %} {% for role in entry.roles %}
@@ -61,16 +58,22 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}"> {% for pref in preferences %}
{% for team in entry.teams %} <td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ pref.name) }}">
{% if entry.isTeamleadOf(team) %} {% set metaField = entry.preference(pref.name) %}
{{ widgets.label_team(team, 'success') }} {% if not metaField is null and metaField.value is not null and metaField.value is not empty %}
{% else %} {{ widgets.form_type_value(pref.type, metaField.value, entry) }}
{{ widgets.label_team(team) }}
{% endif %} {% endif %}
{% endfor %}
</td> </td>
{% endfor %}
<td class="text-center {{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% if entry.teams|length > 0 %}
{{ widgets.badge_counter(entry.teams|length) }}
{% endif %}
</td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td>
{% endif %}
<td class="actions"> <td class="actions">
{{ actions.user(entry, 'index') }} {{ actions.user(entry, 'index') }}
</td> </td>

View File

@@ -6,81 +6,104 @@
{% block page_actions %}{{ actions.user(user, tab) }}{% endblock %} {% block page_actions %}{{ actions.user(user, tab) }}{% endblock %}
{% block main %} {% block main %}
{% import _self as macro %}
{% import "macros/widgets.html.twig" as widgets %}
<div class="row"> <div class="row">
<div class="col-md-9"> <div class="col-md-8">
{% block profile_content %}{% endblock %} {% block profile_content %}{% endblock %}
</div> </div>
<div class="col-md-3"> <div class="col-md-4">
{{ macro.profile_box(user, stats) }}
{{ macro.profile_infos(user, stats) }}
</div>
</div>
{% endblock %}
{% macro profile_infos(user, stats) %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
<div class="box box-{{ admin_lte_context.widget.type }}">
<div class="box-header with-border">
<h3 class="box-title">{{ 'profile.about_me'|trans }}</h3>
</div>
<div class="box-body">
<ul class="nav nav-stacked">
{# colors = purple, blue, aqua, red, green #}
<li><a href="#">{{ 'label.id'|trans }} <span class="pull-right badge bg-aqua">{{ user.id }}</span></a></li>
<li><a href="#">{{ 'label.username'|trans }} <span class="pull-right badge bg-blue">{{ user.username }}</span></a></li>
<li><a href="#">{{ 'profile.first_entry'|trans }} <span class="pull-right badge bg-purple">{{ stats.firstEntry|date_short }}</span></a></li>
{% if is_granted('view_rate_own_timesheet') %}
<li><a href="#">{{ 'label.hourlyRate'|trans }} <span class="pull-right badge bg-blue">{{ user.preferenceValue('hourly_rate') }}</span></a></li>
{% endif %}
</ul>
</div>
</div>
{% endmacro %}
{% macro profile_box(user, stats) %}
{% import "@AdminLTE/Macros/default.html.twig" as macro %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% block box_body_class %}box-profile{% endblock %}
<div class="box box-{{ admin_lte_context.widget.type }}"> {% block box_body %}
<div class="box-body box-profile">
<div class="text-center"> <div class="text-center">
{{ widgets.user_avatar(user) }} {{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3> <h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted">{{ user.title }}</p> <p class="text-muted">{{ user.title }}</p>
</div> </div>
{% set seeOwnRate = is_granted('view_rate_own_timesheet') %}
<ul class="list-group list-group-unbordered"> <table class="table">
<tr>
<li class="list-group-item"> <th>{{ 'stats.durationMonth'|trans }}</th>
<b>{{ 'stats.durationMonth'|trans }}</b> <a class="pull-right">{{ stats.durationThisMonth|duration }}</a> <td class="text-nowrap pull-right">{{ stats.durationThisMonth|duration }}</td>
</li> </tr>
{% if seeOwnRate %}
{% if is_granted('view_rate_own_timesheet') %} <tr>
<li class="list-group-item"> <th>{{ 'stats.amountMonth'|trans }}</th>
<b>{{ 'stats.amountMonth'|trans }}</b> <a class="pull-right">{{ stats.amountThisMonth|money }}</a> <td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
</li> </tr>
{% endif %} {% endif %}
<tr>
<li class="list-group-item"> <th>{{ 'stats.durationTotal'|trans }}</th>
<b>{{ 'stats.durationTotal'|trans }}</b> <a class="pull-right">{{ stats.durationTotal|duration }}</a> <td class="text-nowrap pull-right">{{ stats.durationTotal|duration }}</td>
</li> </tr>
{% if seeOwnRate %}
{% if is_granted('view_rate_own_timesheet') %} <tr>
<li class="list-group-item"> <th>{{ 'stats.amountTotal'|trans }}</th>
<b>{{ 'stats.amountTotal'|trans }}</b> <a class="pull-right">{{ stats.amountTotal|money }}</a> <td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
</li> </tr>
{% endif %} {% endif %}
</table>
{% endblock %}
{% endembed %}
</ul> {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}{{ 'profile.about_me'|trans }}{% endblock %}
{% block box_body %}
{# colors = purple, blue, aqua, red, green #}
<table class="table no-border">
<tr>
<th>{{ 'label.id'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.id }}</td>
</tr>
<tr>
<th>{{ 'label.username'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.username }}</td>
</tr>
<tr>
<th>{{ 'profile.first_entry'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.firstEntry|date_short }}</td>
</tr>
{% if is_granted('hourly-rate', user) %}
<tr>
<th>{{ 'label.hourlyRate'|trans }}</th>
<td class="text-nowrap pull-right">{{ user.preferenceValue('hourly_rate') }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endembed %}
{% if user.teams is not empty and (is_granted('teams', user) or (app.user == user and is_granted('view_team_member'))) %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'label.my_teams'|trans }}{% endblock %}
{% block box_tools %}
{% if is_granted('roles', user) %}
<a class="btn-box-tool" href="{{ path('user_profile_teams', {'username': user.username}) }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_body %}
{{ widgets.team_list(user.teams) }}
{% endblock %}
{% endembed %}
{% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.teamUpdate', true);
});
</script>
{% endblock %}
{# -------------------------------- UNUSED FOR NOW -------------------------------- #} {# -------------------------------- UNUSED FOR NOW -------------------------------- #}
{% macro profile_list_unused(user, items) %} {% macro profile_list_unused(user, items) %}

View File

@@ -53,8 +53,6 @@
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('kimai.userRoleUpdate', function() { KimaiReloadPageWidget.create('kimai.userRoleUpdate');
document.location.reload();
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,74 +1,18 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="box"> {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% if not title is empty %} {% block box_title %}
<div class="box-header with-border"> {% if not title is empty %}{{ title|trans }}{% endif %}
<h3 class="box-title">{{ title|trans }}</h3> {% endblock %}
{# {% block box_body %}
<div class="box-tools pull-right">
<button type="button" class="btn btn-box-tool" data-widget="collapse"><i class="fa fa-minus"></i>
</button>
<div class="btn-group">
<button type="button" class="btn btn-box-tool dropdown-toggle" data-toggle="dropdown">
<i class="fa fa-wrench"></i></button>
<ul class="dropdown-menu" role="menu">
<li><a href="#">Action</a></li>
<li><a href="#">Another action</a></li>
<li><a href="#">Something else here</a></li>
<li class="divider"></li>
<li><a href="#">Separated link</a></li>
</ul>
</div>
<button type="button" class="btn btn-box-tool" data-widget="remove"><i class="fa fa-times"></i></button>
</div>
#}
</div>
{% endif %}
<div class="box-body">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{{ render_widget(widgets.0) }} {{ render_widget(widgets.0) }}
</div> </div>
{#
<div class="col-md-4">
<p class="text-center">
<strong>Goal Completion</strong>
</p>
<div class="progress-group">
<span class="progress-text">Add Products to Cart</span>
<span class="progress-number"><b>160</b>/200</span>
<div class="progress sm">
<div class="progress-bar progress-bar-aqua" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Complete Purchase</span>
<span class="progress-number"><b>310</b>/400</span>
<div class="progress sm">
<div class="progress-bar progress-bar-red" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Visit Premium Page</span>
<span class="progress-number"><b>480</b>/800</span>
<div class="progress sm">
<div class="progress-bar progress-bar-green" style="width: 80%"></div>
</div>
</div>
<div class="progress-group">
<span class="progress-text">Send Inquiries</span>
<span class="progress-number"><b>250</b>/500</span>
<div class="progress sm">
<div class="progress-bar progress-bar-yellow" style="width: 80%"></div>
</div>
</div>
</div>
#}
</div>
</div> </div>
{% endblock %}
{% block box_footer %}
{% if widgets|length > 1 %} {% if widgets|length > 1 %}
<div class="box-footer">
<div class="row"> <div class="row">
{% set width = (widgets|length) - 1 %} {% set width = (widgets|length) - 1 %}
{% set rawWidth = 12 / width %} {% set rawWidth = 12 / width %}
@@ -82,18 +26,14 @@
{% endif %} {% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}"> <div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
<div class="description-block border-right"> <div class="description-block border-right">
{#<span class="description-percentage text-green"><i class="fa fa-caret-up"></i> 17%</span>#}
{#<span class="description-percentage text-yellow"><i class="fa fa-caret-left"></i> 0%</span>#}
{#<span class="description-percentage text-green"><i class="fa fa-caret-up"></i> 20%</span>#}
{#<span class="description-percentage text-red"><i class="fa fa-caret-down"></i> 18%</span>#}
<h5 class="description-header">{{ data }}</h5> <h5 class="description-header">{{ data }}</h5>
<span class="description-text">{{ widget.title|trans }}</span> <span class="description-text">{{ widget.title|trans }}</span>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endif %} {% endif %}
</div> {% endblock %}
{% endembed %}
</div> </div>
</div> </div>

View File

@@ -8,18 +8,17 @@
{% if projectStats|length > 0 %} {% if projectStats|length > 0 %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="box box-{{ admin_lte_context.widget.type }} WidgetUserTeamProjects" id="{{ widgetId }}"> {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% if not title is empty %} {% block box_attributes %}id="{{ widgetId }}"{% endblock %}
<div class="box-header with-border"> {% block box_title %}
<h3 class="box-title">{{ title|trans }}</h3> {% if not title is empty %}{{ title|trans }}{% endif %}
</div> {% endblock %}
{% endif %} {% block box_body %}
<div class="box-body">
<table class="table table-hover dataTable" role="grid"> <table class="table table-hover dataTable" role="grid">
<thead> <thead>
<tr> <tr>
<th>{{ 'label.project'|trans }}</th> <th>{{ 'label.project'|trans }}</th>
<th class="hidden-xs">{{ 'label.team'|trans }}</th> <th class="hidden-xs hidden-sm hidden-md">{{ 'label.team'|trans }}</th>
<th style="width:50%">{{ 'label.progress'|trans }}</th> <th style="width:50%">{{ 'label.progress'|trans }}</th>
</tr> </tr>
</thead> </thead>
@@ -32,7 +31,7 @@
<br> <br>
<small>{{ widgets.label_customer(stats.project.customer) }}</small> <small>{{ widgets.label_customer(stats.project.customer) }}</small>
</td> </td>
<td class="hidden-xs"> <td class="hidden-xs hidden-sm hidden-md">
{% for team in project.teams %} {% for team in project.teams %}
{% if app.user.isInTeam(team) %} {% if app.user.isInTeam(team) %}
{{ widgets.label_team(team) }} {{ widgets.label_team(team) }}
@@ -52,9 +51,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> {% endblock %}
</div> {% endembed %}
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

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

View File

@@ -54,7 +54,6 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
default: default:
throw new \Exception(sprintf('Unknown role "%s"', $role)); throw new \Exception(sprintf('Unknown role "%s"', $role));
break;
} }
return $client; return $client;

View File

@@ -47,7 +47,7 @@ class CreateUserCommandTest extends KernelTestCase
$output = $commandTester->getDisplay(); $output = $commandTester->getDisplay();
$this->assertStringContainsString('[ERROR] plainPassword (foobar)', $output); $this->assertStringContainsString('[ERROR] plainPassword (foobar)', $output);
$this->assertStringContainsString('The password is too short.', $output); $this->assertStringContainsString('This value is too short. It should have 8 characters or more.', $output);
} }
public function testCreateUser() public function testCreateUser()

View File

@@ -299,7 +299,7 @@ abstract class ControllerBaseTest extends WebTestCase
protected function assertHasFlashSuccess(Client $client, string $message = null) protected function assertHasFlashSuccess(Client $client, string $message = null)
{ {
$node = $client->getCrawler()->filter('div.alert.alert-success.alert-dismissible'); $node = $client->getCrawler()->filter('div.alert.alert-success.alert-dismissible');
self::assertNotEmpty($node->text()); self::assertGreaterThan(0, $node->count(), 'Could not find flash success message');
if (null !== $message) { if (null !== $message) {
self::assertStringContainsString($message, $node->text()); self::assertStringContainsString($message, $node->text());
} }
@@ -312,7 +312,7 @@ abstract class ControllerBaseTest extends WebTestCase
protected function assertHasFlashError(Client $client, string $message = null) protected function assertHasFlashError(Client $client, string $message = null)
{ {
$node = $client->getCrawler()->filter('div.alert.alert-error.alert-dismissible'); $node = $client->getCrawler()->filter('div.alert.alert-error.alert-dismissible');
self::assertNotEmpty($node->text()); self::assertGreaterThan(0, $node->count(), 'Could not find flash error message');
if (null !== $message) { if (null !== $message) {
self::assertStringContainsString($message, $node->text()); self::assertStringContainsString($message, $node->text());
} }

View File

@@ -11,10 +11,10 @@ namespace App\Tests\Controller;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerMeta; use App\Entity\CustomerMeta;
use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures; use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock; use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
@@ -69,21 +69,133 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_customer_admin', 5); $this->assertDataTableRowCount($client, 'datatable_customer_admin', 5);
} }
public function testBudgetAction() public function testDetailsAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
self::assertHasProgressbar($client);
$node = $client->getCrawler()->filter('div.box#customer_details_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#project_list_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#comments_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
self::assertEquals(2, $node->count());
}
public function testAddCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'A beautiful and short comment **with some** markdown formatting',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('<p>A beautiful and short comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testDeleteCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'Blah foo bar',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.confirmation-link');
self::assertEquals($this->createUrl('/admin/customer/1/comment_delete'), $node->attr('href'));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/customer/1/comment_delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
public function testPinCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
$client->submit($form, [
'customer_comment_form' => [
'message' => 'Blah foo bar',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('Blah foo bar', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
self::assertEquals(0, $node->count());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/customer/1/comment_pin');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
self::assertEquals(1, $node->count());
self::assertEquals($this->createUrl('/admin/customer/1/comment_pin'), $node->attr('href'));
}
public function testCreateDefaultTeamAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/details');
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/customer/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testProjectsAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/projects/1');
$node = $client->getCrawler()->filter('div.box#project_list_box .box-body table tbody tr');
self::assertEquals(1, $node->count());
/** @var EntityManager $em */ /** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$customer = $em->getRepository(Customer::class)->find(1);
$fixture = new TimesheetFixtures(); $fixture = new ProjectFixtures();
$fixture->setAmount(10); $fixture->setAmount(9); // to trigger a second page (every third activity is hidden)
$fixture->setProjects($em->getRepository(Project::class)->findAll()); $fixture->setCustomers([$customer]);
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/budget'); $this->assertAccessIsGranted($client, '/admin/customer/1/projects/1');
self::assertHasProgressbar($client);
$node = $client->getCrawler()->filter('div.box#project_list_box .box-tools ul.pagination li');
self::assertEquals(4, $node->count());
$node = $client->getCrawler()->filter('div.box#project_list_box .box-body table tbody tr');
self::assertEquals(5, $node->count());
} }
public function testCreateAction() public function testCreateAction()
@@ -107,9 +219,8 @@ class CustomerControllerTest extends ControllerBaseTest
'name' => 'Test Customer', 'name' => 'Test Customer',
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/')); $this->assertIsRedirect($client, $this->createUrl('/admin/customer/2/details'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client);
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
} }
@@ -137,9 +248,8 @@ class CustomerControllerTest extends ControllerBaseTest
'name' => 'Test Customer 2' 'name' => 'Test Customer 2'
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/')); $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/customer/1/edit'); $this->request($client, '/admin/customer/1/edit');
$editForm = $client->getCrawler()->filter('form[name=customer_edit_form]')->form(); $editForm = $client->getCrawler()->filter('form[name=customer_edit_form]')->form();
$this->assertEquals('Test Customer 2', $editForm->get('customer_edit_form[name]')->getValue()); $this->assertEquals('Test Customer 2', $editForm->get('customer_edit_form[name]')->getValue());

View File

@@ -38,9 +38,9 @@ class DashboardControllerTest extends ControllerBaseTest
]); ]);
$this->request($client, '/dashboard/'); $this->request($client, '/dashboard/');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
self::assertEquals(1, $client->getCrawler()->filter('section.content .WidgetUserTeams')->count()); self::assertEquals(1, $client->getCrawler()->filter('section.content #WidgetUserTeams')->count());
// team 1 has no project assignment right now // team 1 has no project assignment right now
self::assertEquals(0, $client->getCrawler()->filter('section.content .WidgetUserTeamProjects')->count()); self::assertEquals(0, $client->getCrawler()->filter('section.content #WidgetUserTeamProjects')->count());
} }
public function testIndexActionForAdmin() public function testIndexActionForAdmin()

View File

@@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 83); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 101);
$this->assertPageActions($client, [ $this->assertPageActions($client, [
'back' => $this->createUrl('/admin/user/'), 'back' => $this->createUrl('/admin/user/'),
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), 'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -88,7 +88,7 @@ class ProfileControllerTest extends ControllerBaseTest
$content = $client->getResponse()->getContent(); $content = $client->getResponse()->getContent();
$this->assertStringContainsString('<h3 class="box-title">About me</h3>', $content); $this->assertStringContainsString('<h3 class="box-title">About me</h3>', $content);
$this->assertStringContainsString('<span class="pull-right badge bg-blue">' . $username . '</span>', $content); $this->assertStringContainsString('<td class="text-nowrap pull-right">' . $username . '</td>', $content);
} }
public function getTabTestData() public function getTabTestData()
@@ -133,8 +133,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/edit'); $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/edit');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER); $user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername()); $this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
@@ -209,8 +209,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/password'); $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/password');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER); $user = $this->getUserByRole($em, User::ROLE_USER);
/** @var EncoderFactoryInterface $passwordEncoder */ /** @var EncoderFactoryInterface $passwordEncoder */
@@ -248,8 +248,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/api-token'); $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/api-token');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER); $user = $this->getUserByRole($em, User::ROLE_USER);
/** @var EncoderFactoryInterface $passwordEncoder */ /** @var EncoderFactoryInterface $passwordEncoder */
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder'); $passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
@@ -293,8 +293,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/roles'); $this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/roles');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER); $user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals(['ROLE_USER'], $user->getRoles()); $this->assertEquals(['ROLE_USER'], $user->getRoles());
@@ -385,8 +385,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser($role); $client = $this->getClientForAuthenticatedUser($role);
$this->request($client, '/profile/' . $username . '/prefs'); $this->request($client, '/profile/' . $username . '/prefs');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByName($em, $username); $user = $this->getUserByName($em, $username);
$this->assertEquals($hourlyRateOriginal, $user->getPreferenceValue(UserPreference::HOURLY_RATE)); $this->assertEquals($hourlyRateOriginal, $user->getPreferenceValue(UserPreference::HOURLY_RATE));

View File

@@ -13,6 +13,7 @@ use App\Entity\Project;
use App\Entity\ProjectMeta; use App\Entity\ProjectMeta;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures; use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures; use App\Tests\DataFixtures\TeamFixtures;
@@ -69,21 +70,150 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_project_admin', 5); $this->assertDataTableRowCount($client, 'datatable_project_admin', 5);
} }
public function testBudgetAction() public function testDetailsAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */ /** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$project = $em->getRepository(Project::class)->find(1);
$fixture = new TimesheetFixtures(); $fixture = new TimesheetFixtures();
$fixture->setAmount(10); $fixture->setAmount(10);
$fixture->setProjects($em->getRepository(Project::class)->findAll()); $fixture->setProjects([$project]);
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN)); $fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
$project = $em->getRepository(Project::class)->find(1);
$fixture = new ActivityFixtures();
$fixture->setAmount(6); // to trigger a second page
$fixture->setProjects([$project]);
$this->importFixture($em, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/budget'); $this->assertAccessIsGranted($client, '/admin/project/1/details');
self::assertHasProgressbar($client); self::assertHasProgressbar($client);
$node = $client->getCrawler()->filter('div.box#project_details_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_list_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#comments_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
self::assertEquals(2, $node->count());
}
public function testAddCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
$client->submit($form, [
'project_comment_form' => [
'message' => 'A beautiful and long comment **with some** markdown formatting',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('<p>A beautiful and long comment <strong>with some</strong> markdown formatting</p>', $node->html());
}
public function testDeleteCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
$client->submit($form, [
'project_comment_form' => [
'message' => 'Foo bar blub',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.confirmation-link');
self::assertEquals($this->createUrl('/admin/project/1/comment_delete'), $node->attr('href'));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/project/1/comment_delete');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('There were no comments posted yet', $node->html());
}
public function testPinCommentAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$form = $client->getCrawler()->filter('form[name=project_comment_form]')->form();
$client->submit($form, [
'project_comment_form' => [
'message' => 'Foo bar blub',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box div.box-comments');
self::assertStringContainsString('Foo bar blub', $node->html());
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
self::assertEquals(0, $node->count());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/project/1/comment_pin');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#comments_box .box-comment a.btn.active');
self::assertEquals(1, $node->count());
self::assertEquals($this->createUrl('/admin/project/1/comment_pin'), $node->attr('href'));
}
public function testCreateDefaultTeamAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/details');
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body');
self::assertStringContainsString('Visible to everyone, as no team was assigned yet.', $node->text());
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/admin/project/1/create_team');
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body');
self::assertStringContainsString('Only visible to the following teams and all admins.', $node->text());
$node = $client->getCrawler()->filter('div.box#team_listing_box .box-body table tbody tr');
self::assertEquals(1, $node->count());
}
public function testActivitiesAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/activities/1');
self::assertEquals('', $client->getResponse()->getContent());
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$project = $em->getRepository(Project::class)->find(1);
$fixture = new ActivityFixtures();
$fixture->setAmount(9); // to trigger a second page (every third activity is hidden)
$fixture->setProjects([$project]);
$this->importFixture($em, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/activities/1');
$node = $client->getCrawler()->filter('div.box#activity_list_box .box-tools ul.pagination li');
self::assertEquals(4, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_list_box .box-body table tbody tr');
self::assertEquals(5, $node->count());
} }
public function testCreateAction() public function testCreateAction()
@@ -98,9 +228,8 @@ class ProjectControllerTest extends ControllerBaseTest
'name' => 'Test 2', 'name' => 'Test 2',
] ]
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/')); $this->assertIsRedirect($client, $this->createUrl('/admin/project/2/details'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client);
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
} }
@@ -160,9 +289,8 @@ class ProjectControllerTest extends ControllerBaseTest
$client->submit($form, [ $client->submit($form, [
'project_edit_form' => ['name' => 'Test 2'] 'project_edit_form' => ['name' => 'Test 2']
]); ]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/')); $this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect(); $client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/project/1/edit'); $this->request($client, '/admin/project/1/edit');
$editForm = $client->getCrawler()->filter('form[name=project_edit_form]')->form(); $editForm = $client->getCrawler()->filter('form[name=project_edit_form]')->form();
$this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue()); $this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue());

View File

@@ -219,7 +219,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
$client->submit($form, [ $client->submit($form, [
'system_configuration_form_theme' => [ 'system_configuration_form_theme' => [
'configuration' => [ 'configuration' => [
['name' => 'theme.select_type', 'value' => 'selectpicker'], ['name' => 'theme.autocomplete_chars', 'value' => 5],
['name' => 'timesheet.markdown_content', 'value' => 1], ['name' => 'timesheet.markdown_content', 'value' => 1],
] ]
] ]

View File

@@ -123,6 +123,29 @@ class TeamControllerTest extends ControllerBaseTest
$this->assertEquals('Test Team 2', $editForm->get('team_edit_form[name]')->getValue()); $this->assertEquals('Test Team 2', $editForm->get('team_edit_form[name]')->getValue());
} }
public function testEditMemberAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TeamFixtures();
$fixture->setAmount(2);
$this->importFixture($em, $fixture);
$this->assertAccessIsGranted($client, '/admin/teams/1/edit_member');
$form = $client->getCrawler()->filter('form[name=team_edit_form]')->form();
$this->assertNotEmpty($form->get('team_edit_form[name]')->getValue());
$client->submit($form, [
'team_edit_form' => [
'name' => 'Test Team 2'
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/teams/1/edit'));
$client->followRedirect();
$editForm = $client->getCrawler()->filter('form[name=team_edit_form]')->form();
$this->assertEquals('Test Team 2', $editForm->get('team_edit_form[name]')->getValue());
}
public function testEditCustomerAccessAction() public function testEditCustomerAccessAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

@@ -12,7 +12,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Project; use App\Entity\Project;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**
@@ -36,6 +36,10 @@ final class ActivityFixtures extends Fixture
* @var callable * @var callable
*/ */
private $callback; private $callback;
/**
* @var Project[]
*/
private $projects = [];
/** /**
* Will be called prior to persisting the object. * Will be called prior to persisting the object.
@@ -79,15 +83,28 @@ final class ActivityFixtures extends Fixture
return $this; return $this;
} }
/**
* @param Project[] $projects
* @return ActivityFixtures
*/
public function setProjects(array $projects): ActivityFixtures
{
$this->projects = $projects;
return $this;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
$projects = $this->projects;
if (empty($projects)) {
$projects = $this->getAllProjects($manager); $projects = $this->getAllProjects($manager);
}
$faker = Factory::create(); $faker = Factory::create();
// random amount of timesheet entries for every user
for ($i = 0; $i < $this->amount; $i++) { for ($i = 0; $i < $this->amount; $i++) {
$project = null; $project = null;
if (false === $this->isGlobal) { if (false === $this->isGlobal) {

View File

@@ -11,7 +11,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\Customer; use App\Entity\Customer;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**

View File

@@ -11,7 +11,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**

View File

@@ -12,7 +12,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\Project; use App\Entity\Project;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**
@@ -32,6 +32,10 @@ final class ProjectFixtures extends Fixture
* @var callable * @var callable
*/ */
private $callback; private $callback;
/**
* @var Customer[]
*/
private $customers = [];
public function getAmount(): int public function getAmount(): int
{ {
@@ -52,6 +56,17 @@ final class ProjectFixtures extends Fixture
return $this; return $this;
} }
/**
* @param Customer[] $customers
* @return ProjectFixtures
*/
public function setCustomers(array $customers): ProjectFixtures
{
$this->customers = $customers;
return $this;
}
/** /**
* Will be called prior to persisting the object. * Will be called prior to persisting the object.
* *
@@ -70,7 +85,10 @@ final class ProjectFixtures extends Fixture
*/ */
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
$customers = $this->customers;
if (empty($customers)) {
$customers = $this->getAllCustomers($manager); $customers = $this->getAllCustomers($manager);
}
$faker = Factory::create(); $faker = Factory::create();
for ($i = 0; $i < $this->amount; $i++) { for ($i = 0; $i < $this->amount; $i++) {

View File

@@ -11,7 +11,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\Tag; use App\Entity\Tag;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
/** /**
* Defines the sample data to load in during controller tests. * Defines the sample data to load in during controller tests.

View File

@@ -13,7 +13,7 @@ use App\Entity\Customer;
use App\Entity\Team; use App\Entity\Team;
use App\Entity\User; use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**

View File

@@ -17,7 +17,7 @@ use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Timesheet\Util; use App\Timesheet\Util;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
/** /**

View File

@@ -0,0 +1,50 @@
<?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\Entity;
use App\Entity\CommentInterface;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
abstract class AbstractCommentEntityTest extends TestCase
{
abstract protected function getEntity(): CommentInterface;
public function testDefaultValues()
{
$sut = $this->getEntity();
self::assertNull($sut->getId());
self::assertNull($sut->getMessage());
self::assertNull($sut->getCreatedBy());
self::assertNotNull($sut->getCreatedAt());
self::assertInstanceOf(\DateTime::class, $sut->getCreatedAt());
self::assertFalse($sut->isPinned());
}
public function testSetterAndGetter()
{
$sut = $this->getEntity();
$sut->setPinned(true);
self::assertTrue($sut->isPinned());
$user = new User();
$sut->setCreatedBy($user);
self::assertSame($user, $sut->getCreatedBy());
$date = new \DateTime();
$sut->setCreatedAt($date);
self::assertSame($date, $sut->getCreatedAt());
$sut->setMessage('slödkfjaölsdkjflaksjdfölaksjdfölakjsdöfl');
self::assertEquals('slödkfjaölsdkjflaksjdfölaksjdfölakjsdöfl', $sut->getMessage());
}
}

View File

@@ -0,0 +1,36 @@
<?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\Entity;
use App\Entity\CommentInterface;
use App\Entity\Customer;
use App\Entity\CustomerComment;
/**
* @covers \App\Entity\CustomerComment
* @covers \App\Entity\CommentTableTypeTrait
*/
class CustomerCommentTest extends AbstractCommentEntityTest
{
protected function getEntity(): CommentInterface
{
return new CustomerComment();
}
public function testEntitySpecificMethods()
{
$sut = new CustomerComment();
self::assertNull($sut->getCustomer());
$customer = new Customer();
self::assertInstanceOf(CustomerComment::class, $sut->setCustomer($customer));
self::assertSame($customer, $sut->getCustomer());
}
}

View File

@@ -0,0 +1,36 @@
<?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\Entity;
use App\Entity\CommentInterface;
use App\Entity\Project;
use App\Entity\ProjectComment;
/**
* @covers \App\Entity\ProjectComment
* @covers \App\Entity\CommentTableTypeTrait
*/
class ProjectCommentTest extends AbstractCommentEntityTest
{
protected function getEntity(): CommentInterface
{
return new ProjectComment();
}
public function testEntitySpecificMethods()
{
$sut = new ProjectComment();
self::assertNull($sut->getProject());
$project = new Project();
self::assertInstanceOf(ProjectComment::class, $sut->setProject($project));
self::assertSame($project, $sut->getProject());
}
}

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