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 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
**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 will run into an error otherwise during the update.
You need to delete the bundle before updating: `rm -r var/plugins/ExpenseBundle`, otherwise you will run into errors 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.
### 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/).
After the Kimai update was successful, you have to re-install the latest bundle version, which is compatible with Kimai 1.7 only.
### Developer

View File

@@ -74,6 +74,7 @@ require('./sass/app.scss');
// ------ Kimai itself ------
require('./js/KimaiWebLoader.js');
global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default;
global.KimaiReloadPageWidget = require('./js/widgets/KimaiReloadPageWidget').default;
// ------ Autocomplete for tags only ------
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

@@ -48,4 +48,9 @@ td {
.label-gray {
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 {
/* action column */
.actions {
width: 90px;
li.delete a {
color: #dd4b39;
}
width: 40px;
}
tr {
/* summary row - provided in the users own timesheet table */

View File

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

View File

@@ -1,23 +1,24 @@
#!/usr/bin/env 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 Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Debug\Debug;
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 (!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');
}

View File

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

917
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"])
sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','delete_activity']
ACTIVITIES_TEAM: ['view_activity','create_activity','edit_teamlead_activity','budget_teamlead_activity']
PROJECTS: ['view_project','create_project','edit_project','budget_project','delete_project','permissions_project']
PROJECTS_TEAM: ['view_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project']
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer']
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer']
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','comments_project','details_project']
PROJECTS_ALL_TEAMLEAD: ['view_teamlead_project','edit_teamlead_project','budget_teamlead_project','permissions_teamlead_project','comments_teamlead_project','details_teamlead_project']
PROJECTS_ALL_TEAM: ['view_team_project','edit_team_project','budget_team_project','comments_team_project','details_team_project']
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_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_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
PROFILE_OTHER: ['view_other_profile','edit_other_profile','delete_other_profile','password_other_profile','roles_other_profile','preferences_other_profile','api-token_other_profile','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']
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
@@ -107,10 +111,10 @@ kimai:
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_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
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_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"])
@@ -119,6 +123,8 @@ kimai:
ROLE_TEAMLEAD: ['ROLE_TEAMLEAD']
ROLE_ADMIN: ['ROLE_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
roles:
ROLE_USER: []

View File

@@ -1,6 +1,7 @@
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-symfony/rules.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
@@ -10,15 +11,15 @@ parameters:
# container_xml_path: '%rootDir%/../../../var/cache/dev/srcApp_KernelDevDebugContainer.xml'
ignoreErrors:
- '#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\\NodeDefinition::useAttributeAsKey\(\).#'
- '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeParentInterface::integerNode\(\).#'
- '#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::requiresAtLeastOneElement\(\).#'
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'
- '#Access to an undefined property Faker\\Generator::\$bs.#'
- '#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:
- %rootDir%/../../../src/Command/KimaiImporterCommand.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/0.1a6ffb43.js",
"build/1.16767f92.js",
"build/app.055e71d1.js"
"build/app.6ea4e95f.js"
],
"css": [
"build/app.483d7949.css"
"build/app.8240996d.css"
]
},
"chart": {
@@ -35,8 +35,8 @@
"build/runtime.6ca1eea5.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.1a6ffb43.js": "sha384-vuVCyLVj2b4h3jpKl+JSANRCacmXeLk+ihK5T5NZBO7+SJ5Y+o6d4qju116hTPSD",
"build/1.16767f92.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.055e71d1.js": "sha384-NGXBt+8vQ5h4orQ5B82W9GoeVpu4bNmiJI7k/goKm1ogp2JGvo0tyadJdoOf3Dt+",
"build/app.483d7949.css": "sha384-u8/TZyq87MTQTKScxUGmbJcWEBhsoXAlsUTB8XSgKvuufZ5bODWYm1o1E1DRosJu",
"build/app.6ea4e95f.js": "sha384-mHnCng8TBnNQoqH7ZSnkexDEYgODkjaDJIN5BpK6dSAP7dhIcQ5dUY19NtgfjUsZ",
"build/app.8240996d.css": "sha384-ZaS4ro6HGbDfLyaJc8cVXJzTd8plX3mRzhHhiiUEErSzPlRKdZfViDk23BNvAaz+",
"build/2.dad56560.js": "sha384-oKsefSmRq0GL+Qa6f6jExXvFay3cM6YZFHQRKoAHi2p54UOCtjJfjF2eAB1qykoL",
"build/chart.7f4d7f29.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.9e7cdb6a.js": "sha384-RvFQJC1YvKq9BqRDObc5REPbi9ZiG5/fkqKP1xLJUlFDb2b8WqTR/f4F+hdE9cI1",

View File

@@ -2,8 +2,8 @@
"build/0.1a6ffb43.js": "build/0.1a6ffb43.js",
"build/1.16767f92.js": "build/1.16767f92.js",
"build/2.dad56560.js": "build/2.dad56560.js",
"build/app.css": "build/app.483d7949.css",
"build/app.js": "build/app.055e71d1.js",
"build/app.css": "build/app.8240996d.css",
"build/app.js": "build/app.6ea4e95f.js",
"build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.9e7cdb6a.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');
}
/**
* Currently unused, as JS selects are always activated.
* @deprecated since 1.7 will be removed with 2.0
*/
public function getSelectPicker(): string
{
return (string) $this->find('select_type');

View File

@@ -11,17 +11,24 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Team;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Form\CustomerCommentForm;
use App\Form\CustomerEditForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
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.
*
* @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
*/
private $repository;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @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->configuration = $configuration;
$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="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated", methods={"GET"})
* @Security("is_granted('view_customer')")
*/
public function indexAction($page, Request $request)
{
@@ -85,7 +78,7 @@ class CustomerController extends AbstractController
$query->resetByFormError($form->getErrors());
}
$entries = $this->getRepository()->getPagerfantaForQuery($query);
$entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('customer/index.html.twig', [
'entries' => $entries,
@@ -99,7 +92,7 @@ class CustomerController extends AbstractController
* @param CustomerQuery $query
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(CustomerQuery $query): array
private function findMetaColumns(CustomerQuery $query): array
{
$event = new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::CUSTOMER);
$this->dispatcher->dispatch($event);
@@ -111,16 +104,16 @@ class CustomerController extends AbstractController
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*/
public function createAction(Request $request)
public function createAction(Request $request, FormConfiguration $configuration)
{
$timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone();
if (null !== $configuration->getCustomerDefaultTimezone()) {
$timezone = $configuration->getCustomerDefaultTimezone();
}
$customer = new Customer();
$customer->setCountry($this->configuration->getCustomerDefaultCountry());
$customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
$customer->setCountry($configuration->getCustomerDefaultCountry());
$customer->setCurrency($configuration->getCustomerDefaultCurrency());
$customer->setTimezone($timezone);
return $this->renderCustomerForm($customer, $request);
@@ -130,7 +123,7 @@ class CustomerController extends AbstractController
* @Route(path="/{id}/permissions", name="admin_customer_permissions", methods={"GET", "POST"})
* @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, [
'action' => $this->generateUrl('admin_customer_permissions', ['id' => $customer->getId()]),
@@ -141,11 +134,11 @@ class CustomerController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveCustomer($customer);
$this->repository->saveCustomer($customer);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$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"})
* @Security("is_granted('budget', customer)")
* @Route(path="/{id}/comment_delete", name="customer_comment_delete", methods={"GET"})
* @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,
'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,
'team' => $defaultTeam,
'teams' => $teams,
'now' => new \DateTime('now', $timezone),
]);
}
@@ -187,7 +322,7 @@ class CustomerController extends AbstractController
*/
public function deleteAction(Customer $customer, Request $request)
{
$stats = $this->getRepository()->getCustomerStatistics($customer);
$stats = $this->repository->getCustomerStatistics($customer);
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
@@ -215,7 +350,7 @@ class CustomerController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->getRepository()->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
@@ -236,21 +371,18 @@ class CustomerController extends AbstractController
* @param Request $request
* @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->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->getRepository()->saveCustomer($customer);
$this->repository->saveCustomer($customer);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_customer');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (ORMException $ex) {
$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, [
'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
{
$event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event);
if ($customer->getId() === null) {
$url = $this->generateUrl('admin_customer_create');
} else {

View File

@@ -13,16 +13,21 @@ use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Entity\ProjectComment;
use App\Entity\Team;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Form\ProjectCommentForm;
use App\Form\ProjectEditForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use Doctrine\ORM\ORMException;
use App\Repository\TeamRepository;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -33,12 +38,12 @@ use Symfony\Component\HttpFoundation\Response;
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")
* @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
@@ -51,7 +56,7 @@ class ProjectController extends AbstractController
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
private $dispatcher;
public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
@@ -60,15 +65,9 @@ class ProjectController extends AbstractController
$this->dispatcher = $dispatcher;
}
protected function getRepository(): ProjectRepository
{
return $this->repository;
}
/**
* @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"})
* @Security("is_granted('view_project')")
*/
public function indexAction($page, Request $request)
{
@@ -85,7 +84,7 @@ class ProjectController extends AbstractController
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->getPagerfantaForQuery($query);
$entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('project/index.html.twig', [
'entries' => $entries,
@@ -122,11 +121,11 @@ class ProjectController extends AbstractController
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->getRepository()->saveProject($project);
$this->repository->saveProject($project);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_project');
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$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"})
* @Security("is_granted('budget', project)")
* @Route(path="/{id}/comment_delete", name="project_comment_delete", methods={"GET"})
* @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,
'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)
{
$stats = $this->getRepository()->getProjectStatistics($project);
$stats = $this->repository->getProjectStatistics($project);
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
@@ -213,9 +348,9 @@ class ProjectController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->getRepository()->deleteProject($project, $deleteForm->get('project')->getData());
$this->repository->deleteProject($project, $deleteForm->get('project')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
@@ -234,17 +369,14 @@ class ProjectController extends AbstractController
* @param Request $request
* @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->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->getRepository()->saveProject($project);
$this->repository->saveProject($project);
$this->flashSuccess('action.update.success');
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);
$project = $newProject;
} 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()]);
}
}
@@ -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
{
$event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event);
$currency = $this->configuration->getCustomerDefaultCurrency();
$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\SystemConfiguration as SystemConfigurationModel;
use App\Form\SystemConfigurationForm;
use App\Form\Type\EnhancedSelectboxType;
use App\Form\Type\LanguageType;
use App\Form\Type\RoundingModeType;
use App\Form\Type\SkinType;
@@ -293,25 +292,20 @@ class SystemConfigurationController extends AbstractController
->setSection(SystemConfigurationModel::SECTION_THEME)
->setConfiguration([
(new Configuration())
->setName('theme.select_type')
->setTranslationDomain('system-configuration')
->setType(EnhancedSelectboxType::class)
->setRequired(false),
->setName('theme.autocomplete_chars')
->setLabel('theme.autocomplete_chars')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('timesheet.markdown_content')
->setLabel('theme.markdown_content')
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('theme.autocomplete_chars')
->setLabel('theme.autocomplete_chars')
->setType(IntegerType::class)
->setTranslationDomain('system-configuration'),
// FIXME should that be configurable per user?
// TODO should that be configurable per user?
/*
(new Configuration())
->setName('theme.auto_reload_datatable')
->setLabel('theme.auto_reload_datatable') // FIXME translation
->setLabel('theme.auto_reload_datatable') // TODO translation
->setType(CheckboxType::class)
->setTranslationDomain('system-configuration'),
*/

View File

@@ -16,7 +16,6 @@ use App\Form\TeamProjectForm;
use App\Form\Toolbar\TeamToolbarForm;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
@@ -28,7 +27,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/admin/teams")
* @Security("is_granted('view_team')")
*/
class TeamController extends AbstractController
final class TeamController extends AbstractController
{
/**
* @var TeamRepository
@@ -93,6 +92,40 @@ class TeamController extends AbstractController
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
{
$customerForm = null;
@@ -122,7 +155,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
@@ -142,7 +175,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
@@ -161,7 +194,7 @@ class TeamController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_team_edit', ['id' => $team->getId()]);
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$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;
/**
* @return array
* @return class-string[]
*/
public function getDependencies()
{

View File

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

View File

@@ -31,10 +31,10 @@ class Configuration implements ConfigurationInterface
public function getConfigTreeBuilder()
{
$treeBuilder = new TreeBuilder('kimai');
/** @var ArrayNodeDefinition $rootNode */
$rootNode = $treeBuilder->getRootNode();
/** @var ArrayNodeDefinition $node */
$node = $treeBuilder->getRootNode();
$rootNode
$node
->children()
->scalarNode('data_dir')
->isRequired()
@@ -75,7 +75,7 @@ class Configuration implements ConfigurationInterface
protected function getTimesheetNode()
{
$builder = new TreeBuilder('timesheet');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -205,7 +205,7 @@ class Configuration implements ConfigurationInterface
protected function getInvoiceNode()
{
$builder = new TreeBuilder('invoice');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -232,7 +232,7 @@ class Configuration implements ConfigurationInterface
protected function getLanguagesNode()
{
$builder = new TreeBuilder('languages');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -256,7 +256,7 @@ class Configuration implements ConfigurationInterface
protected function getCalendarNode()
{
$builder = new TreeBuilder('calendar');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -310,7 +310,7 @@ class Configuration implements ConfigurationInterface
protected function getThemeNode()
{
$builder = new TreeBuilder('theme');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -326,6 +326,7 @@ class Configuration implements ConfigurationInterface
->end()
->scalarNode('select_type')
->defaultValue('selectpicker')
->setDeprecated()
->end()
->scalarNode('auto_reload_datatable')
->defaultFalse()
@@ -374,7 +375,7 @@ class Configuration implements ConfigurationInterface
protected function getIndustryNode()
{
$builder = new TreeBuilder('industry');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -390,7 +391,7 @@ class Configuration implements ConfigurationInterface
protected function getUserNode()
{
$builder = new TreeBuilder('user');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -411,7 +412,7 @@ class Configuration implements ConfigurationInterface
protected function getWidgetsNode()
{
$builder = new TreeBuilder('widgets');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -438,7 +439,7 @@ class Configuration implements ConfigurationInterface
protected function getDashboardNode()
{
$builder = new TreeBuilder('dashboard');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -467,7 +468,7 @@ class Configuration implements ConfigurationInterface
protected function getDefaultsNode()
{
$builder = new TreeBuilder('defaults');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$node
@@ -499,7 +500,7 @@ class Configuration implements ConfigurationInterface
protected function getPermissionsNode()
{
$builder = new TreeBuilder('permissions');
/** @var ArrayNodeDefinition $rootNode */
/** @var ArrayNodeDefinition $node */
$node = $builder->getRootNode();
$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);
}
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->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'admin_customer_budget', 'admin_customer_edit', 'admin_customer_delete']);
$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->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'admin_project_budget', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects);

View File

@@ -59,7 +59,7 @@ class ActivityEditForm extends AbstractType
],
])
->add('comment', TextareaType::class, [
'label' => 'label.comment',
'label' => 'label.description',
'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,
])
->add('comment', TextareaType::class, [
'label' => 'label.comment',
'label' => 'label.description',
'required' => false,
])
->add('company', TextType::class, [

View File

@@ -9,7 +9,6 @@
namespace App\Form\Extension;
use App\Configuration\ThemeConfiguration;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractTypeExtension;
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.
*/
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 function __construct(ThemeConfiguration $configuration)
{
$this->type = $configuration->getSelectPicker();
}
public const TYPE_SELECTPICKER = 'selectpicker';
public static function getExtendedTypes(): iterable
{
@@ -46,10 +38,6 @@ class EnhancedChoiceTypeExtension extends AbstractTypeExtension
*/
public function buildView(FormView $view, FormInterface $form, array $options)
{
if ($this->type !== self::TYPE_SELECTPICKER) {
return;
}
if (isset($options['selectpicker']) && false === $options['selectpicker']) {
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'];
$id = $entry->getId();
if ($id !== null) {
if (null !== $entry->getCustomer()) {
$customer = $entry->getCustomer();
$options['currency'] = $customer->getCurrency();
}
@@ -52,7 +52,7 @@ class ProjectEditForm extends AbstractType
],
])
->add('comment', TextareaType::class, [
'label' => 'label.comment',
'label' => 'label.description',
'required' => false,
])
->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()) {
$where->add($qb->expr()->isNull('a.project'));
} elseif (null !== $query->getProject()) {
$where->add(
$qb->expr()->orX(
$qb->expr()->eq('a.project', ':project'),
$qb->expr()->isNull('a.project')
)
$orX = $qb->expr()->orX(
$qb->expr()->eq('a.project', ':project')
);
if (!$query->isExcludeGlobals()) {
$orX->add($qb->expr()->isNull('a.project'));
}
$where->add($orX);
$qb->setParameter('project', $query->getProject());
} elseif (null !== $query->getCustomer()) {
$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;
}
@@ -352,6 +360,7 @@ class ActivityRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('a.id'))
;

View File

@@ -11,6 +11,7 @@ namespace App\Repository;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
@@ -208,9 +209,13 @@ class CustomerRepository extends EntityRepository
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('c')
$qb
->select('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()) {
$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;
}
@@ -278,6 +288,7 @@ class CustomerRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('c.id'))
;
@@ -336,4 +347,33 @@ class CustomerRepository extends EntityRepository
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\Project;
use App\Entity\ProjectComment;
use App\Entity\Timesheet;
use App\Entity\User;
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;
}
@@ -360,6 +366,7 @@ class ProjectRepository extends EntityRepository
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->resetDQLPart('groupBy')
->select($qb->expr()->countDistinct('p.id'))
;
@@ -438,4 +445,33 @@ class ProjectRepository extends EntityRepository
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
*/
private $globalsOnly = false;
/**
* @var bool
*/
private $excludeGlobals = false;
public function __construct()
{
@@ -54,6 +58,18 @@ class ActivityQuery extends ProjectQuery
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
*/

View File

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

View File

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

View File

@@ -15,9 +15,9 @@ use Twig\Extension\AbstractExtension;
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
@@ -26,10 +26,9 @@ class MarkdownExtension extends AbstractExtension
/**
* @var TimesheetConfiguration
*/
protected $configuration;
private $configuration;
/**
* MarkdownExtension constructor.
* @param Markdown $parser
*/
public function __construct(Markdown $parser, TimesheetConfiguration $configuration)
@@ -46,10 +45,36 @@ class MarkdownExtension extends AbstractExtension
return [
new TwigFilter('md2html', [$this, 'markdownToHtml'], ['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.
*

View File

@@ -12,19 +12,18 @@ namespace App\Utils;
/**
* A simple class to parse markdown syntax and return HTML.
*/
class Markdown
final class Markdown
{
/**
* @var ParsedownExtension
*/
private $parser;
/**
* Markdown constructor.
*/
public function __construct()
{
$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 */
foreach ($project->getCustomer()->getTeams() as $team) {
foreach ($customer->getTeams() as $team) {
if ($hasTeamleadPermission && $user->isTeamleadOf($team)) {
return true;
}

View File

@@ -15,25 +15,22 @@ use App\Entity\User;
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
{
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 = [
self::VIEW,
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
'view',
'create',
'edit',
'budget',
'delete',
'permissions',
'comments',
'details',
];
/**
@@ -72,6 +69,11 @@ class CustomerVoter extends AbstractVoter
return true;
}
// those cannot be assigned to teams
if (in_array($attribute, ['create', 'delete'])) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_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
{
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 = [
self::VIEW,
self::EDIT,
self::BUDGET,
self::DELETE,
self::PERMISSIONS,
'view',
'edit',
'budget',
'delete',
'permissions',
'comments',
'details',
];
/**
@@ -72,6 +68,11 @@ class ProjectVoter extends AbstractVoter
return true;
}
// those cannot be assigned to teams
if (in_array($attribute, ['create', 'delete'])) {
return false;
}
$hasTeamleadPermission = $this->hasRolePermission($user, $attribute . '_teamlead_project');
$hasTeamPermission = $this->hasRolePermission($user, $attribute . '_team_project');

View File

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

View File

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

View File

@@ -201,9 +201,6 @@
"jdorn/sql-formatter": {
"version": "v1.2.17"
},
"jean85/pretty-package-versions": {
"version": "1.2"
},
"jms/metadata": {
"version": "1.6.0"
},
@@ -267,30 +264,6 @@
"nesbot/carbon": {
"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": {
"version": "v4.0.2"
},
@@ -342,9 +315,6 @@
"phpspec/prophecy": {
"version": "1.7.3"
},
"phpstan/phpdoc-parser": {
"version": "0.3.3"
},
"phpstan/phpstan": {
"version": "0.11.7"
},

View File

@@ -28,6 +28,9 @@
{% if is_granted('budget', activity) %}
{% set actions = actions|merge({'report': {'url': path('admin_activity_budget', {'id': activity.id})}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% 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})}) %}
{% endif %}
@@ -39,12 +42,12 @@
{% endif %}
{% endif %}
{% if view != 'index' %}
{% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_activity')}) %}
{% endif %}
{% 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) }}
{% else %}
{{ widgets.entity_actions(event.payload.actions) }}

View File

@@ -4,19 +4,23 @@
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "activity/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = {
'name': 'alwaysVisible',
'customer': 'hidden-xs',
'project': 'hidden-xs',
'comment': 'hidden-xs hidden-sm',
'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
} %}
{% for field in metaColumns %}
{% set columns = columns|merge({
('mf_' ~ field.name): {'title': field.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false},
'actions': 'actions alwaysVisible',
}) %}
@@ -40,16 +44,16 @@
{% 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 %}>
<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') }}">
{# only none-global activities have a project and customer assigned #}
{% if entry.project %}
{# only none-global activities have a project and customer assigned #}
{{ widgets.label_project(entry.project) }}
{% if entry.project.customer %}
<br>
<small>
{{ widgets.label_customer(entry.project.customer) }}
</small>
{% endif %}
{% endif %}
</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) }}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions">
{{ actions.activity(entry, 'index') }}
</td>

View File

@@ -198,7 +198,7 @@
{% block javascripts %}
{# 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">
document.addEventListener('DOMContentLoaded', function() {
window.addEventListener('load', function() {
var loader = new KimaiWebLoader(
{
login: '{{ path('fos_user_security_login') }}',

View File

@@ -18,6 +18,9 @@
{% set actions = {} %}
{% 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) %}
{% set class = '' %}
{% if view != 'edit' %}
@@ -25,13 +28,17 @@
{% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_customer_edit', {'id': customer.id}), 'class': class}}) %}
{% 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) %}
{% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id})}}) %}
{% set class = '' %}
{% if view != 'permissions' %}
{% set class = 'modal-ajax-form' %}
{% endif %}
{% set actions = actions|merge({'permissions': {'url': path('admin_customer_permissions', {'id': customer.id}), 'class': class}}) %}
{% endif %}
{% if is_granted('view_project') %}
{% 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})}) %}
{% endif %}
{% if is_granted('view_activity') %}
@@ -48,12 +55,12 @@
{% endif %}
{% endif %}
{% if view != 'index' %}
{% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_customer')}) %}
{% endif %}
{% 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) }}
{% else %}
{{ 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,65 +23,60 @@
</div>
</div>
{{ form_row(form.comment) }}
<fieldset>
{#<legend>Company data</legend>#}
<div class="row">
<div class="col-md-8">
{{ form_row(form.company) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
{{ form_row(form.address) }}
<div class="row">
<div class="col-md-4">
{{ form_row(form.company) }}
</div>
{{ form_row(form.address) }}
<div class="row">
<div class="col-md-4">
{{ form_row(form.country) }}
</div>
<div class="col-md-4">
{{ form_row(form.currency) }}
</div>
<div class="col-md-4">
{{ form_row(form.timezone) }}
</div>
<div class="col-md-4">
{{ form_row(form.number) }}
</div>
</fieldset>
<fieldset>
{#<legend>Contact data</legend>#}
<div class="row">
<div class="col-md-4">
{{ form_row(form.contact) }}
</div>
<div class="col-md-4">
{{ form_row(form.email) }}
</div>
<div class="col-md-4">
{{ form_row(form.homepage) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.mobile) }}
</div>
<div class="col-md-4">
{{ form_row(form.phone) }}
</div>
<div class="col-md-4">
{{ form_row(form.fax) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.country) }}
</div>
</fieldset>
<fieldset>
{#<legend>Financial data</legend>#}
<div class="row">
<div class="col-md-6">
{{ form_row(form.fixedRate) }}
</div>
<div class="col-md-6">
{{ form_row(form.hourlyRate) }}
</div>
<div class="col-md-4">
{{ form_row(form.currency) }}
</div>
{% if form.budget is defined %}
<div class="col-md-4">
{{ form_row(form.timezone) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.contact) }}
</div>
<div class="col-md-4">
{{ form_row(form.email) }}
</div>
<div class="col-md-4">
{{ form_row(form.homepage) }}
</div>
</div>
<div class="row">
<div class="col-md-4">
{{ form_row(form.mobile) }}
</div>
<div class="col-md-4">
{{ form_row(form.phone) }}
</div>
<div class="col-md-4">
{{ form_row(form.fax) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.fixedRate) }}
</div>
<div class="col-md-6">
{{ form_row(form.hourlyRate) }}
</div>
</div>
{% if form.budget is defined %}
<div class="row">
<div class="col-md-6">
{{ form_row(form.budget) }}
@@ -90,14 +85,10 @@
{{ form_row(form.timeBudget) }}
</div>
</div>
{% endif %}
</fieldset>
{% endif %}
{{ form_row(form.visible) }}
{% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset>
{#<legend>Custom fields</legend>#}
{{ form_row(form.metaFields) }}
</fieldset>
{{ form_row(form.metaFields) }}
{% endif %}
{{ form_widget(form) }}
{% 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 "customer/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = {
'name': 'alwaysVisible',
'comment': 'hidden-xs',
'country': 'hidden-xs hidden-sm',
'number': 'hidden-xs',
'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
} %}
{% for field in metaColumns %}
{% set columns = columns|merge({
@@ -16,12 +15,14 @@
}) %}
{% endfor %}
{% 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},
'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
@@ -40,47 +41,21 @@
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% 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 %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_customer_edit', {'id': entry.id}) }}"{% endif %}>
<td>{{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }} {% if entry.company is not empty %}({{ entry.company }}){% endif %}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'country') }}">{{ entry.country|country }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td>
<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) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">
{{ entry.comment|comment2html }}
</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% 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') }}">
{% if entry.teams|length > 0 %}
{{ widgets.badge_counter(entry.teams|length) }}
@@ -88,7 +63,9 @@
{{ widgets.icon('unlocked') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions">
{{ actions.customer(entry, 'index') }}
</td>

View File

@@ -1,21 +1,30 @@
<div class="box box-solid box-danger">
<div class="box-header">
<h3 class="box-title">{{ title|default('confirm.delete'|trans) }}</h3>
</div>
<div class="box-body">
{{ message|raw }}
</div>
{{ form_start(form) }}
<div class="box-body">
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'boxtype': 'danger'} %}
{% block box_before %}
{{ form_start(form) }}
{% endblock %}
{% block box_title %}
{{ title|default('confirm.delete'|trans) }}
{% endblock %}
{% block box_body %}
{% block form_body %}
{{ form_widget(form) }}
{% if used is same as (false) %}
{{ 'delete.not_in_use'|trans }}
<div class="hidden">
{{ form_widget(form) }}
</div>
{% else %}
<p>{{ message|raw }}</p>
{{ form_widget(form) }}
{% endif %}
{% endblock %}
</div>
<div class="box-footer">
{% endblock %}
{% block box_footer %}
<input type="submit" value="{{ 'action.delete'|trans }}" class="btn btn-danger" />
{% if back %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %}
</div>
{{ form_end(form) }}
</div>
{% endblock %}
{% block box_after %}
{{ form_end(form) }}
{% 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 %}
<div class="breadcrumb">
<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>
{%- endmacro -%}
@@ -69,11 +79,10 @@
{% macro user_avatar(user, tooltip, class) %}
{% set avatar = avatar(user, admin_lte_context.default_avatar) %}
{% set showTooltip = tooltip|default(true) %}
{% if tooltip is not defined or tooltip is same as (false)%}
<img src="{{ avatar }}" class="{{ class|default('img-circle') }}" alt="{{ user.displayName }}" />
{% if tooltip is same as (false) %}
<img src="{{ avatar }}" class="img-circle{% if class is not empty %} {{ class }}{% endif %}" alt="{{ user.displayName }}" />
{% else %}
<img src="{{ avatar }}" class="{{ 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 %}
{% endmacro %}
@@ -200,30 +209,50 @@
</div>
{% endmacro %}
{% macro table_actions(actions) %}
{% macro table_actions(actions, class) %}
{%- import _self as macro -%}
{% if actions|length >= 1 %}
<div class="btn-group">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-expanded="false">{{ 'label.actions'|trans }}
<span class="fa fa-caret-down"></span></button>
{% if class is null %}
{% set class = 'btn-sm' %}
{% 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">
{%- apply spaceless -%}
{%- for icon,values in actions %}
{% set class = '' %}
{% if icon == 'trash' %}
{% set class = 'delete' %}
{% if actions|length > 1 %}
<li class="divider"></li>
{% if icon == 'divider' and values is null %}
{% if not loop.last and divider is same as (false) %}
<li class="divider"></li>
{% endif %}
{% set divider = true %}
{% else %}
{% if values is iterable %}
{% set values = values|merge({'title': icon|trans({}, 'actions')}) %}
{% else %}
{% set values = {'url': values, 'title': icon|trans({}, 'actions')} %}
{% endif %}
{% if icon == 'trash' %}
{% set trash = values %}
{% else %}
{% set divider = false %}
<li>
{{ macro.action_button(icon, values, false) }}
</li>
{% endif %}
{% endif %}
<li class="{{ class }}">
{% if values is iterable %}
{{ macro.action_button(icon, values|merge({'title': icon|trans({}, 'actions')}), false) }}
{% else %}
{{ macro.action_button(icon, {'url': values, 'title': icon|trans({}, 'actions')}, false) }}
{% endif %}
</li>
{% 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 %}
</ul>
</div>
@@ -371,3 +400,49 @@
{{ value }}
{% endif %}
{% 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 = {} %}
{% 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) %}
{% set class = '' %}
{% if view != 'edit' %}
@@ -25,11 +28,15 @@
{% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_project_edit', {'id': project.id}), 'class': class}}) %}
{% 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) %}
{% 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 %}
{% if is_granted('view_activity') %}
{% set actions = actions|merge({'activity': path('admin_activity', {'customer': project.customer.id, 'project': project.id})}) %}
@@ -45,12 +52,12 @@
{% endif %}
{% endif %}
{% if view != 'index' %}
{% if view != 'index' and view != 'custom' %}
{% set actions = actions|merge({'back': path('admin_project')}) %}
{% endif %}
{% 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) }}
{% else %}
{{ 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 "project/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = {
'name': 'alwaysVisible',
'customer': 'hidden-xs',
'comment': 'hidden-xs hidden-sm',
'orderNumber': 'hidden-xs hidden-sm',
'orderDate': 'hidden-xs hidden-sm',
'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans},
} %}
{% for field in metaColumns %}
{% set columns = columns|merge({
@@ -18,7 +17,13 @@
{% endfor %}
{% set columns = columns|merge({
'team': {'class': 'text-center', 'orderBy': false},
'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% if showVisibility %}
{% set columns = columns|merge({
'visible': {'class': 'text-center', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
@@ -37,21 +42,15 @@
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% 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 %}
<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 class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">
{{ widgets.label_customer(entry.customer) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|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>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment2html() }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
@@ -64,7 +63,9 @@
{{ widgets.icon('unlocked') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'visible') }}">{{ widgets.label_visible(entry.visible) }}</td>
{% endif %}
<td class="actions">
{{ actions.project(entry, 'index') }}
</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}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% 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'}}}) %}
{% endif %}

View File

@@ -47,6 +47,10 @@
{% set actions = actions|merge({'edit': {'url': path('timesheet_edit', {'id': timesheet.id}), 'class': class}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}
{% 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'}}}) %}
{% endif %}

View File

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

View File

@@ -4,21 +4,28 @@
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "user/actions.html.twig" as actions %}
{% set showVisibility = query.visibility != 1 %}
{% set columns = {
'alias': 'alwaysVisible',
'username': 'hidden-xs',
'email': 'hidden-xs hidden-md hidden-sm',
'title': 'hidden-xs hidden-sm',
} %}
{% set columns = columns|merge({
'roles': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for pref in preferences %}
{% set columns = columns|merge({
('mf_' ~ pref.name): {'title': pref.label, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% set columns = columns|merge({
'roles': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'team': {'class': 'hidden-xs', 'orderBy': false},
'active': {'class': '', 'orderBy': false},
'team': {'class': 'text-center hidden-xs', 'orderBy': false},
}) %}
{% if showVisibility %}
{% set columns = columns|merge({
'active': {'class': '', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'actions': 'actions alwaysVisible',
}) %}
@@ -40,19 +47,9 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}
{% 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 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>
{% 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') }}">
{% set showUserRole = entry.roles|length == 1 %}
{% for role in entry.roles %}
@@ -61,16 +58,22 @@
{% endif %}
{% endfor %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'team') }}">
{% for team in entry.teams %}
{% if entry.isTeamleadOf(team) %}
{{ widgets.label_team(team, 'success') }}
{% else %}
{{ widgets.label_team(team) }}
{% 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 %}
{% endfor %}
</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>
<td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td>
{% if showVisibility %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'active') }}">{{ widgets.label_visible(entry.enabled) }}</td>
{% endif %}
<td class="actions">
{{ actions.user(entry, 'index') }}
</td>

View File

@@ -6,81 +6,104 @@
{% block page_actions %}{{ actions.user(user, tab) }}{% endblock %}
{% block main %}
{% import _self as macro %}
{% import "macros/widgets.html.twig" as widgets %}
<div class="row">
<div class="col-md-9">
<div class="col-md-8">
{% block profile_content %}{% endblock %}
</div>
<div class="col-md-3">
{{ macro.profile_box(user, stats) }}
{{ macro.profile_infos(user, stats) }}
<div class="col-md-4">
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_body_class %}box-profile{% endblock %}
{% block box_body %}
<div class="text-center">
{{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted">{{ user.title }}</p>
</div>
{% set seeOwnRate = is_granted('view_rate_own_timesheet') %}
<table class="table">
<tr>
<th>{{ 'stats.durationMonth'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationThisMonth|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountMonth'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.amountThisMonth|money }}</td>
</tr>
{% endif %}
<tr>
<th>{{ 'stats.durationTotal'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.durationTotal|duration }}</td>
</tr>
{% if seeOwnRate %}
<tr>
<th>{{ 'stats.amountTotal'|trans }}</th>
<td class="text-nowrap pull-right">{{ stats.amountTotal|money }}</td>
</tr>
{% endif %}
</table>
{% endblock %}
{% endembed %}
{% 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>
{% endblock %}
{% macro profile_infos(user, stats) %}
<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 %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.teamUpdate', true);
});
</script>
{% endblock %}
{% macro profile_box(user, stats) %}
{% import "@AdminLTE/Macros/default.html.twig" as macro %}
{% import "macros/widgets.html.twig" as widgets %}
<div class="box box-{{ admin_lte_context.widget.type }}">
<div class="box-body box-profile">
<div class="text-center">
{{ widgets.user_avatar(user) }}
<h3 class="profile-username">{{ widgets.username(user) }}</h3>
<p class="text-muted">{{ user.title }}</p>
</div>
<ul class="list-group list-group-unbordered">
<li class="list-group-item">
<b>{{ 'stats.durationMonth'|trans }}</b> <a class="pull-right">{{ stats.durationThisMonth|duration }}</a>
</li>
{% if is_granted('view_rate_own_timesheet') %}
<li class="list-group-item">
<b>{{ 'stats.amountMonth'|trans }}</b> <a class="pull-right">{{ stats.amountThisMonth|money }}</a>
</li>
{% endif %}
<li class="list-group-item">
<b>{{ 'stats.durationTotal'|trans }}</b> <a class="pull-right">{{ stats.durationTotal|duration }}</a>
</li>
{% if is_granted('view_rate_own_timesheet') %}
<li class="list-group-item">
<b>{{ 'stats.amountTotal'|trans }}</b> <a class="pull-right">{{ stats.amountTotal|money }}</a>
</li>
{% endif %}
</ul>
</div>
</div>
{% endmacro %}
{# -------------------------------- UNUSED FOR NOW -------------------------------- #}
{% macro profile_list_unused(user, items) %}

View File

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

View File

@@ -1,99 +1,39 @@
<div class="row">
<div class="col-md-12">
<div class="box">
{% if not title is empty %}
<div class="box-header with-border">
<h3 class="box-title">{{ title|trans }}</h3>
{#
<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">
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% block box_title %}
{% if not title is empty %}{{ title|trans }}{% endif %}
{% endblock %}
{% block box_body %}
<div class="row">
<div class="col-md-12">
{{ render_widget(widgets.0) }}
</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>
{% endblock %}
{% block box_footer %}
{% if widgets|length > 1 %}
<div class="row">
{% set width = (widgets|length) - 1 %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
{% for widget in widgets|slice(1, width) %}
{% set data = widget.data %}
{% if widget.option('dataType') == 'duration' %}
{% set data = widget.data|duration %}
{% elseif widget.option('dataType') == 'money' %}
{% set data = widget.data|money %}
{% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
<div class="description-block border-right">
<h5 class="description-header">{{ data }}</h5>
<span class="description-text">{{ widget.title|trans }}</span>
</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>
{% endfor %}
</div>
#}
</div>
</div>
{% if widgets|length > 1 %}
<div class="box-footer">
<div class="row">
{% set width = (widgets|length) - 1 %}
{% set rawWidth = 12 / width %}
{% set columnWidth = rawWidth|round(0, 'floor') %}
{% for widget in widgets|slice(1, width) %}
{% set data = widget.data %}
{% if widget.option('dataType') == 'duration' %}
{% set data = widget.data|duration %}
{% elseif widget.option('dataType') == 'money' %}
{% set data = widget.data|money %}
{% endif %}
<div class="col-sm-{{ columnWidth }} col-xs-{{ columnWidth * 2 }}">
<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>
<span class="description-text">{{ widget.title|trans }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% endembed %}
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ class CreateUserCommandTest extends KernelTestCase
$output = $commandTester->getDisplay();
$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()

View File

@@ -299,7 +299,7 @@ abstract class ControllerBaseTest extends WebTestCase
protected function assertHasFlashSuccess(Client $client, string $message = null)
{
$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) {
self::assertStringContainsString($message, $node->text());
}
@@ -312,7 +312,7 @@ abstract class ControllerBaseTest extends WebTestCase
protected function assertHasFlashError(Client $client, string $message = null)
{
$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) {
self::assertStringContainsString($message, $node->text());
}

View File

@@ -11,10 +11,10 @@ namespace App\Tests\Controller;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\CustomerTestMetaFieldSubscriberMock;
@@ -69,21 +69,133 @@ class CustomerControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_customer_admin', 5);
}
public function testBudgetAction()
public function testDetailsAction()
{
$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 */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setProjects($em->getRepository(Project::class)->findAll());
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$customer = $em->getRepository(Customer::class)->find(1);
$fixture = new ProjectFixtures();
$fixture->setAmount(9); // to trigger a second page (every third activity is hidden)
$fixture->setCustomers([$customer]);
$this->importFixture($em, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/budget');
self::assertHasProgressbar($client);
$this->assertAccessIsGranted($client, '/admin/customer/1/projects/1');
$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()
@@ -107,9 +219,8 @@ class CustomerControllerTest extends ControllerBaseTest
'name' => 'Test Customer',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/'));
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/2/details'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->assertHasFlashSuccess($client);
}
@@ -137,9 +248,8 @@ class CustomerControllerTest extends ControllerBaseTest
'name' => 'Test Customer 2'
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/'));
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/customer/1/edit');
$editForm = $client->getCrawler()->filter('form[name=customer_edit_form]')->form();
$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->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
self::assertEquals(0, $client->getCrawler()->filter('section.content .WidgetUserTeamProjects')->count());
self::assertEquals(0, $client->getCrawler()->filter('section.content #WidgetUserTeamProjects')->count());
}
public function testIndexActionForAdmin()

View File

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

View File

@@ -88,7 +88,7 @@ class ProfileControllerTest extends ControllerBaseTest
$content = $client->getResponse()->getContent();
$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()
@@ -133,8 +133,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/edit');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals(UserFixtures::USERNAME_USER, $user->getUsername());
@@ -209,8 +209,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/password');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
/** @var EncoderFactoryInterface $passwordEncoder */
@@ -248,8 +248,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/api-token');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
/** @var EncoderFactoryInterface $passwordEncoder */
$passwordEncoder = $client->getContainer()->get('test.PasswordEncoder');
@@ -293,8 +293,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/roles');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByRole($em, User::ROLE_USER);
$this->assertEquals(['ROLE_USER'], $user->getRoles());
@@ -385,8 +385,8 @@ class ProfileControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser($role);
$this->request($client, '/profile/' . $username . '/prefs');
/** @var User $user */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
/** @var User $user */
$user = $this->getUserByName($em, $username);
$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\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TeamFixtures;
@@ -69,21 +70,150 @@ class ProjectControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_project_admin', 5);
}
public function testBudgetAction()
public function testDetailsAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$project = $em->getRepository(Project::class)->find(1);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setProjects($em->getRepository(Project::class)->findAll());
$fixture->setProjects([$project]);
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$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);
$this->assertAccessIsGranted($client, '/admin/project/1/budget');
$this->assertAccessIsGranted($client, '/admin/project/1/details');
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()
@@ -98,9 +228,8 @@ class ProjectControllerTest extends ControllerBaseTest
'name' => 'Test 2',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/'));
$this->assertIsRedirect($client, $this->createUrl('/admin/project/2/details'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->assertHasFlashSuccess($client);
}
@@ -160,9 +289,8 @@ class ProjectControllerTest extends ControllerBaseTest
$client->submit($form, [
'project_edit_form' => ['name' => 'Test 2']
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/'));
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$this->assertHasDataTable($client);
$this->request($client, '/admin/project/1/edit');
$editForm = $client->getCrawler()->filter('form[name=project_edit_form]')->form();
$this->assertEquals('Test 2', $editForm->get('project_edit_form[name]')->getValue());

View File

@@ -219,7 +219,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
$client->submit($form, [
'system_configuration_form_theme' => [
'configuration' => [
['name' => 'theme.select_type', 'value' => 'selectpicker'],
['name' => 'theme.autocomplete_chars', 'value' => 5],
['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());
}
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()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ namespace App\Tests\DataFixtures;
use App\Entity\Tag;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectManager;
/**
* 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\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectManager;
use Faker\Factory;
/**

View File

@@ -17,7 +17,7 @@ use App\Entity\User;
use App\Entity\UserPreference;
use App\Timesheet\Util;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Doctrine\Persistence\ObjectManager;
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