added user-specific rates (#1455)

This commit is contained in:
Kevin Papst
2020-02-10 20:29:43 +01:00
committed by GitHub
parent 465d7166d4
commit 52c7437076
83 changed files with 2054 additions and 520 deletions

View File

@@ -1,4 +1,4 @@
name: Code coverage name: CI
on: on:
pull_request: null pull_request: null
push: push:
@@ -11,7 +11,7 @@ jobs:
matrix: matrix:
php: ['7.4'] php: ['7.4']
name: PHP ${{ matrix.php }} name: Coverage - PHP ${{ matrix.php }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: shivammathur/setup-php@v1 - uses: shivammathur/setup-php@v1

View File

@@ -1,4 +1,4 @@
name: Code Styles name: CI
on: on:
pull_request: null pull_request: null
push: push:
@@ -11,7 +11,7 @@ jobs:
matrix: matrix:
php: ['7.4'] php: ['7.4']
name: PHP ${{ matrix.php }} name: Linting - PHP ${{ matrix.php }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: shivammathur/setup-php@v1 - uses: shivammathur/setup-php@v1

View File

@@ -1,4 +1,4 @@
name: Tests & Migrations name: CI
on: on:
pull_request: null pull_request: null
push: push:
@@ -21,7 +21,7 @@ jobs:
matrix: matrix:
php: ['7.2', '7.3', '7.4'] php: ['7.2', '7.3', '7.4']
name: PHP ${{ matrix.php }} name: Tests - PHP ${{ matrix.php }}
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: shivammathur/setup-php@v1 - uses: shivammathur/setup-php@v1

View File

@@ -1,9 +1,9 @@
# Kimai 2 - online time-tracker # Kimai 2 - online time-tracker
[![CI Status](https://github.com/kevinpapst/kimai2/workflows/CI/badge.svg)](https://github.com/kevinpapst/kimai2/actions)
[![Code Coverage](https://codecov.io/gh/kevinpapst/kimai2/branch/master/graph/badge.svg)](https://codecov.io/gh/kevinpapst/kimai2)
[![Latest Stable Version](https://poser.pugx.org/kevinpapst/kimai2/v/stable)](https://packagist.org/packages/kevinpapst/kimai2) [![Latest Stable Version](https://poser.pugx.org/kevinpapst/kimai2/v/stable)](https://packagist.org/packages/kevinpapst/kimai2)
[![License](https://poser.pugx.org/kevinpapst/kimai2/license)](https://packagist.org/packages/kevinpapst/kimai2) [![License](https://poser.pugx.org/kevinpapst/kimai2/license)](https://packagist.org/packages/kevinpapst/kimai2)
[![Travis Status](https://travis-ci.org/kevinpapst/kimai2.svg?branch=master)](https://travis-ci.org/kevinpapst/kimai2)
[![Code Coverage](https://codecov.io/gh/kevinpapst/kimai2/branch/master/graph/badge.svg)](https://codecov.io/gh/kevinpapst/kimai2)
[![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support) [![Gitter](https://badges.gitter.im/kimai2/support.svg)](https://gitter.im/kimai2/support)
[![Bountysource](https://img.shields.io/bountysource/team/kimai2/activity)](https://www.bountysource.com/teams/kimai2) [![Bountysource](https://img.shields.io/bountysource/team/kimai2/activity)](https://www.bountysource.com/teams/kimai2)
@@ -20,8 +20,8 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
### Requirements ### Requirements
- PHP 7.2 or higher - PHP 7.2.9 or higher
- Database (MySQL/MariaDB with timezone data, SQLite for development) - Database (MySQL/MariaDB, SQLite for development)
- Webserver (nginx, Apache) - Webserver (nginx, Apache)
- A modern browser - A modern browser
- [Other libraries](https://www.kimai.org/download/) - [Other libraries](https://www.kimai.org/download/)
@@ -32,7 +32,7 @@ This is the new version of the open source timetracker Kimai. It is stable and p
with most advanced features from Kimai 1 and many new ones, including but not limited to: with most advanced features from Kimai 1 and many new ones, including but not limited to:
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones, JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
LDAP and built-in authentication, customizable role permissions, responsive and ready for your mobile device, authentication via SAML/LDAP/Database, customizable role permissions, responsive and ready for your mobile device,
hourly and fixed rates, advanced filtering, money and time budgets with report, support for plugins and many more. hourly and fixed rates, advanced filtering, money and time budgets with report, support for plugins and many more.
## Installation ## Installation

View File

@@ -59,6 +59,7 @@ export default class KimaiReloadPageWidget {
jQuery('section.content').replaceWith( jQuery('section.content').replaceWith(
jQuery(response).find('section.content') jQuery(response).find('section.content')
); );
document.dispatchEvent(new Event('kimai.reloadPage'));
self._hideOverlay(); self._hideOverlay();
}, },
dataType: 'html', dataType: 'html',

View File

@@ -1,6 +1,6 @@
App\Entity\Activity: App\Entity\Activity:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, project, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle] custom_accessor_order: [id, name, comment, visible, project, color, budget, timeBudget, metaFields, parentTitle]
properties: properties:
id: id:
include: true include: true
@@ -20,12 +20,6 @@ App\Entity\Activity:
timeBudget: timeBudget:
include: true include: true
groups: [Entity] groups: [Entity]
fixedRate:
include: true
groups: [Activity]
hourlyRate:
include: true
groups: [Activity]
project: project:
include: false include: false
exclude: true exclude: true

View File

@@ -1,6 +1,6 @@
App\Entity\Customer: App\Entity\Customer:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, teams] custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, color, budget, timeBudget, metaFields, teams]
properties: properties:
id: id:
include: true include: true
@@ -53,12 +53,6 @@ App\Entity\Customer:
timezone: timezone:
include: true include: true
groups: [Entity] groups: [Entity]
fixedRate:
include: true
groups: [Customer]
hourlyRate:
include: true
groups: [Customer]
color: color:
include: true include: true
metaFields: metaFields:

View File

@@ -1,6 +1,6 @@
App\Entity\Project: App\Entity\Project:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, fixedRate, hourlyRate, color, budget, timeBudget, metaFields, parentTitle, teams] custom_accessor_order: [id, name, comment, visible, orderNumber, orderDate, customer, start, end, color, budget, timeBudget, metaFields, parentTitle, teams]
properties: properties:
id: id:
include: true include: true
@@ -29,12 +29,6 @@ App\Entity\Project:
end: end:
include: true include: true
groups: [Project] groups: [Project]
fixedRate:
include: true
groups: [Project]
hourlyRate:
include: true
groups: [Project]
customer: customer:
groups: [Subresource] groups: [Subresource]
color: color:

View File

@@ -211,3 +211,18 @@ services:
App\Repository\InvoiceDocumentRepository: App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%'] arguments: ['%kimai.invoice.documents%']
App\Repository\CustomerRateRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\CustomerRate']
App\Repository\ActivityRateRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ActivityRate']
App\Repository\ProjectRateRepository:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\ProjectRate']

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,7 +5,7 @@
"build/runtime.664a9501.js", "build/runtime.664a9501.js",
"build/0.f7fae2b9.js", "build/0.f7fae2b9.js",
"build/1.c24a9c6f.js", "build/1.c24a9c6f.js",
"build/app.480acc52.js" "build/app.9c136e43.js"
], ],
"css": [ "css": [
"build/app.2bde4971.css" "build/app.2bde4971.css"
@@ -35,7 +35,7 @@
"build/runtime.664a9501.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.664a9501.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT", "build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT",
"build/1.c24a9c6f.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG", "build/1.c24a9c6f.js": "sha384-JPoKdrVtBemSiVBoAnmSxLML7xXM9zYeuwOPYQv/kLzt/P4cmLY5r9gH8oaGRPFG",
"build/app.480acc52.js": "sha384-xYyt4WgTB3KsDmkKZW/GYmkDoh/bFXZM/bVGQ9FddGIqUJ7GRp1hfs9RVTEAUqO5", "build/app.9c136e43.js": "sha384-TDHR6NK2lj2HSkxfJJsyuODZQgu0+fX+3eDlCbyE+KP80nAHNCuTqubCHvSU5Eg5",
"build/app.2bde4971.css": "sha384-3bVuRNKO8FkMuSAu5Eefo+HJ9WNpiJsrIefh0qSdQsbk+8+MA1zre/NZ/AGrY1M9", "build/app.2bde4971.css": "sha384-3bVuRNKO8FkMuSAu5Eefo+HJ9WNpiJsrIefh0qSdQsbk+8+MA1zre/NZ/AGrY1M9",
"build/2.a47ad919.js": "sha384-1h2P/tsl+bh8qgJd7S9irQHKuns7UATVxeFGLOCH85GPFXfAdRzgzx3nk5MrxzrV", "build/2.a47ad919.js": "sha384-1h2P/tsl+bh8qgJd7S9irQHKuns7UATVxeFGLOCH85GPFXfAdRzgzx3nk5MrxzrV",
"build/chart.c9943f57.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.c9943f57.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.c24a9c6f.js": "build/1.c24a9c6f.js", "build/1.c24a9c6f.js": "build/1.c24a9c6f.js",
"build/2.a47ad919.js": "build/2.a47ad919.js", "build/2.a47ad919.js": "build/2.a47ad919.js",
"build/app.css": "build/app.2bde4971.css", "build/app.css": "build/app.2bde4971.css",
"build/app.js": "build/app.480acc52.js", "build/app.js": "build/app.9c136e43.js",
"build/calendar.css": "build/calendar.ade7bcdf.css", "build/calendar.css": "build/calendar.ade7bcdf.css",
"build/calendar.js": "build/calendar.75c80d8f.js", "build/calendar.js": "build/calendar.75c80d8f.js",
"build/chart.js": "build/chart.c9943f57.js", "build/chart.js": "build/chart.c9943f57.js",

View File

@@ -11,17 +11,19 @@ namespace App\Controller;
use App\Configuration\FormConfiguration; use App\Configuration\FormConfiguration;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent; use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent; use App\Event\ActivityMetaDisplayEvent;
use App\Form\ActivityEditForm; use App\Form\ActivityEditForm;
use App\Form\ActivityRateForm;
use App\Form\Toolbar\ActivityToolbarForm; use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType; use App\Form\Type\ActivityType;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery; use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -37,7 +39,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/admin/activity") * @Route(path="/admin/activity")
* @Security("is_granted('view_activity')") * @Security("is_granted('view_activity')")
*/ */
class ActivityController extends AbstractController final class ActivityController extends AbstractController
{ {
/** /**
* @var ActivityRepository * @var ActivityRepository
@@ -50,7 +52,7 @@ class ActivityController extends AbstractController
/** /**
* @var EventDispatcherInterface * @var EventDispatcherInterface
*/ */
protected $dispatcher; private $dispatcher;
public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher) public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{ {
@@ -59,11 +61,6 @@ class ActivityController extends AbstractController
$this->dispatcher = $dispatcher; $this->dispatcher = $dispatcher;
} }
protected function getRepository(): ActivityRepository
{
return $this->repository;
}
/** /**
* @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"}) * @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"})
* @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"}) * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"})
@@ -88,7 +85,7 @@ class ActivityController extends AbstractController
} }
/* @var $entries Pagerfanta */ /* @var $entries Pagerfanta */
$entries = $this->getRepository()->getPagerfantaForQuery($query); $entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('activity/index.html.twig', [ return $this->render('activity/index.html.twig', [
'entries' => $entries, 'entries' => $entries,
@@ -110,6 +107,85 @@ class ActivityController extends AbstractController
return $event->getFields(); return $event->getFields();
} }
/**
* @Route(path="/{id}/details", name="activity_details", methods={"GET", "POST"})
* @Security("is_granted('view', activity)")
*/
public function detailsAction(Activity $activity, ActivityRateRepository $rateRepository)
{
$event = new ActivityMetaDefinitionEvent($activity);
$this->dispatcher->dispatch($event);
$stats = null;
$rates = [];
if ($this->isGranted('edit', $activity)) {
$rates = $rateRepository->getRatesForActivity($activity);
}
if ($this->isGranted('budget', $activity)) {
$stats = $this->repository->getActivityStatistics($activity);
}
return $this->render('activity/details.html.twig', [
'activity' => $activity,
'stats' => $stats,
'rates' => $rates
]);
}
/**
* @Route(path="/{id}/rate_delete/{rate}", name="admin_activity_rate_delete", methods={"GET"})
* @Security("is_granted('edit', activity)")
*/
public function deleteRateAction(Activity $activity, ActivityRate $rate, ActivityRateRepository $repository)
{
if ($rate->getActivity() !== $activity) {
$this->flashError('action.delete.error', ['%reason%' => 'Invalid activity']);
} else {
try {
$repository->deleteRate($rate);
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
}
/**
* @Route(path="/{id}/rate", name="admin_activity_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', activity)")
*/
public function addRateAction(Activity $activity, Request $request, ActivityRateRepository $repository)
{
$rate = new ActivityRate();
$rate->setActivity($activity);
$form = $this->createForm(ActivityRateForm::class, $rate, [
'action' => $this->generateUrl('admin_activity_rate_add', ['id' => $activity->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$repository->saveRate($rate);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('activity_details', ['id' => $activity->getId()]);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('activity/rates.html.twig', [
'activity' => $activity,
'form' => $form->createView()
]);
}
/** /**
* @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"}) * @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"})
* @Route(path="/create/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"}) * @Route(path="/create/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"})
@@ -129,25 +205,6 @@ class ActivityController extends AbstractController
return $this->renderActivityForm($activity, $request); return $this->renderActivityForm($activity, $request);
} }
/**
* @Route(path="/{id}/budget", name="admin_activity_budget", methods={"GET"})
* @Security("is_granted('budget', activity)")
*
* @param Activity $activity
* @return Response
*/
public function budgetAction(Activity $activity)
{
$stats = $this->getRepository()->getActivityStatistics($activity);
// TODO sent event with stats
return $this->render('activity/budget.html.twig', [
'activity' => $activity,
'stats' => $stats,
]);
}
/** /**
* @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"}) * @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', activity)") * @Security("is_granted('edit', activity)")
@@ -171,7 +228,7 @@ class ActivityController extends AbstractController
*/ */
public function deleteAction(Activity $activity, Request $request) public function deleteAction(Activity $activity, Request $request)
{ {
$stats = $this->getRepository()->getActivityStatistics($activity); $stats = $this->repository->getActivityStatistics($activity);
$deleteForm = $this->createFormBuilder(null, [ $deleteForm = $this->createFormBuilder(null, [
'attr' => [ 'attr' => [
@@ -199,9 +256,9 @@ class ActivityController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try { try {
$this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData()); $this->repository->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
} }
@@ -233,7 +290,7 @@ class ActivityController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) { if ($editForm->isSubmitted() && $editForm->isValid()) {
try { try {
$this->getRepository()->saveActivity($activity); $this->repository->saveActivity($activity);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
@@ -245,7 +302,7 @@ class ActivityController extends AbstractController
} else { } else {
return $this->redirectToRoute('admin_activity'); return $this->redirectToRoute('admin_activity');
} }
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }

View File

@@ -12,22 +12,25 @@ namespace App\Controller;
use App\Configuration\FormConfiguration; use App\Configuration\FormConfiguration;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerComment; use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Rate;
use App\Entity\Team; use App\Entity\Team;
use App\Event\CustomerMetaDefinitionEvent; use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent; use App\Event\CustomerMetaDisplayEvent;
use App\Form\CustomerCommentForm; use App\Form\CustomerCommentForm;
use App\Form\CustomerEditForm; use App\Form\CustomerEditForm;
use App\Form\CustomerRateForm;
use App\Form\CustomerTeamPermissionForm; use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm; use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType; use App\Form\Type\CustomerType;
use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery; use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -257,7 +260,7 @@ final class CustomerController extends AbstractController
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"}) * @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
* @Security("is_granted('view', customer)") * @Security("is_granted('view', customer)")
*/ */
public function detailsAction(Customer $customer, TeamRepository $teamRepository) public function detailsAction(Customer $customer, TeamRepository $teamRepository, CustomerRateRepository $rateRepository)
{ {
$event = new CustomerMetaDefinitionEvent($customer); $event = new CustomerMetaDefinitionEvent($customer);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
@@ -270,10 +273,14 @@ final class CustomerController extends AbstractController
$comments = null; $comments = null;
$teams = null; $teams = null;
$projects = null; $projects = null;
$rates = [];
if ($this->isGranted('edit', $customer) && $this->isGranted('create_team')) { if ($this->isGranted('edit', $customer)) {
if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]); $defaultTeam = $teamRepository->findOneBy(['name' => $customer->getName()]);
} }
$rates = $rateRepository->getRatesForCustomer($customer);
}
if (null !== $customer->getTimezone()) { if (null !== $customer->getTimezone()) {
$timezone = new \DateTimeZone($customer->getTimezone()); $timezone = new \DateTimeZone($customer->getTimezone());
@@ -304,6 +311,59 @@ final class CustomerController extends AbstractController
'team' => $defaultTeam, 'team' => $defaultTeam,
'teams' => $teams, 'teams' => $teams,
'now' => new \DateTime('now', $timezone), 'now' => new \DateTime('now', $timezone),
'rates' => $rates
]);
}
/**
* @Route(path="/{id}/rate_delete/{rate}", name="admin_customer_rate_delete", methods={"GET"})
* @Security("is_granted('edit', customer)")
*/
public function deleteRateAction(Customer $customer, CustomerRate $rate, CustomerRateRepository $repository)
{
if ($rate->getCustomer() !== $customer) {
$this->flashError('action.delete.error', ['%reason%' => 'Invalid customer']);
} else {
try {
$repository->deleteRate($rate);
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
}
/**
* @Route(path="/{id}/rate", name="admin_customer_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', customer)")
*/
public function addRateAction(Customer $customer, Request $request, CustomerRateRepository $repository)
{
$rate = new CustomerRate();
$rate->setCustomer($customer);
$form = $this->createForm(CustomerRateForm::class, $rate, [
'action' => $this->generateUrl('admin_customer_rate_add', ['id' => $customer->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$repository->saveRate($rate);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('customer/rates.html.twig', [
'customer' => $customer,
'form' => $form->createView()
]); ]);
} }
@@ -352,7 +412,7 @@ final class CustomerController extends AbstractController
try { try {
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData()); $this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success'); $this->flashSuccess('action.delete.success');
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
} }
@@ -383,7 +443,7 @@ final class CustomerController extends AbstractController
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]); return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (ORMException $ex) { } catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]); $this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
} }
} }

View File

@@ -14,15 +14,19 @@ use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface; use App\Entity\MetaTableTypeInterface;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectComment; use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Rate;
use App\Entity\Team; use App\Entity\Team;
use App\Event\ProjectMetaDefinitionEvent; use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent; use App\Event\ProjectMetaDisplayEvent;
use App\Form\ProjectCommentForm; use App\Form\ProjectCommentForm;
use App\Form\ProjectEditForm; use App\Form\ProjectEditForm;
use App\Form\ProjectRateForm;
use App\Form\ProjectTeamPermissionForm; use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm; use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType; use App\Form\Type\ProjectType;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery; use App\Repository\Query\ProjectFormTypeQuery;
@@ -261,7 +265,7 @@ final class ProjectController extends AbstractController
* @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"}) * @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"})
* @Security("is_granted('view', project)") * @Security("is_granted('view', project)")
*/ */
public function detailsAction(Project $project, TeamRepository $teamRepository) public function detailsAction(Project $project, TeamRepository $teamRepository, ProjectRateRepository $rateRepository)
{ {
$event = new ProjectMetaDefinitionEvent($project); $event = new ProjectMetaDefinitionEvent($project);
$this->dispatcher->dispatch($event); $this->dispatcher->dispatch($event);
@@ -272,10 +276,14 @@ final class ProjectController extends AbstractController
$attachments = []; $attachments = [];
$comments = null; $comments = null;
$teams = null; $teams = null;
$rates = [];
if ($this->isGranted('edit', $project) && $this->isGranted('create_team')) { if ($this->isGranted('edit', $project)) {
if ($this->isGranted('create_team')) {
$defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]); $defaultTeam = $teamRepository->findOneBy(['name' => $project->getName()]);
} }
$rates = $rateRepository->getRatesForProject($project);
}
if ($this->isGranted('budget', $project)) { if ($this->isGranted('budget', $project)) {
$stats = $this->repository->getProjectStatistics($project); $stats = $this->repository->getProjectStatistics($project);
@@ -301,6 +309,59 @@ final class ProjectController extends AbstractController
'stats' => $stats, 'stats' => $stats,
'team' => $defaultTeam, 'team' => $defaultTeam,
'teams' => $teams, 'teams' => $teams,
'rates' => $rates
]);
}
/**
* @Route(path="/{id}/rate_delete/{rate}", name="admin_project_rate_delete", methods={"GET"})
* @Security("is_granted('edit', project)")
*/
public function deleteRateAction(Project $project, ProjectRate $rate, ProjectRateRepository $repository)
{
if ($rate->getProject() !== $project) {
$this->flashError('action.delete.error', ['%reason%' => 'Invalid project']);
} else {
try {
$repository->deleteRate($rate);
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
}
/**
* @Route(path="/{id}/rate", name="admin_project_rate_add", methods={"GET", "POST"})
* @Security("is_granted('edit', project)")
*/
public function addRateAction(Project $project, Request $request, ProjectRateRepository $repository)
{
$rate = new ProjectRate();
$rate->setProject($project);
$form = $this->createForm(ProjectRateForm::class, $rate, [
'action' => $this->generateUrl('admin_project_rate_add', ['id' => $project->getId()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$repository->saveRate($rate);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}
return $this->render('project/rates.html.twig', [
'project' => $project,
'form' => $form->createView()
]); ]);
} }

View File

@@ -74,7 +74,6 @@ class Activity implements EntityWithMetaFields
private $visible = true; private $visible = true;
// keep the trait include exactly here, for placing the column at the correct position // keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait; use ColorTrait;
use BudgetTrait; use BudgetTrait;
@@ -138,6 +137,11 @@ class Activity implements EntityWithMetaFields
return $this; return $this;
} }
public function isGlobal(): bool
{
return $this->project === null;
}
public function isVisible(): bool public function isVisible(): bool
{ {
return $this->visible; return $this->visible;

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_activities_rates",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"user_id", "activity_id"}),
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\ActivityRateRepository")
* @UniqueEntity({"user", "activity"}, ignoreNull=false)
*/
class ActivityRate implements RateInterface
{
use Rate;
/**
* @var Activity
*
* @ORM\ManyToOne(targetEntity="App\Entity\Activity")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $activity;
public function setActivity(?Activity $activity): ActivityRate
{
$this->activity = $activity;
return $this;
}
public function getActivity(): ?Activity
{
return $this->activity;
}
public function getScore(): int
{
return 5;
}
}

View File

@@ -174,7 +174,6 @@ class Customer implements EntityWithMetaFields
private $timezone; private $timezone;
// keep the trait include exactly here, for placing the column at the correct position // keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait; use ColorTrait;
use BudgetTrait; use BudgetTrait;

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_customers_rates",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"user_id", "customer_id"}),
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\CustomerRateRepository")
* @UniqueEntity({"user", "customer"}, ignoreNull=false)
*/
class CustomerRate implements RateInterface
{
use Rate;
/**
* @var Customer
*
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $customer;
public function setCustomer(?Customer $customer): CustomerRate
{
$this->customer = $customer;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function getScore(): int
{
return 1;
}
}

View File

@@ -105,7 +105,6 @@ class Project implements EntityWithMetaFields
private $visible = true; private $visible = true;
// keep the trait include exactly here, for placing the column at the correct position // keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait; use ColorTrait;
use BudgetTrait; use BudgetTrait;

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Table(name="kimai2_projects_rates",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"user_id", "project_id"}),
* }
* )
* @ORM\Entity(repositoryClass="App\Repository\ProjectRateRepository")
* @UniqueEntity({"user", "project"}, ignoreNull=false)
*/
class ProjectRate implements RateInterface
{
use Rate;
/**
* @var Project
*
* @ORM\ManyToOne(targetEntity="App\Entity\Project")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull
*/
private $project;
public function setProject(?Project $project): ProjectRate
{
$this->project = $project;
return $this;
}
public function getProject(): ?Project
{
return $this->project;
}
public function getScore(): int
{
return 3;
}
}

92
src/Entity/Rate.php Normal file
View File

@@ -0,0 +1,92 @@
<?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 Rate
{
/**
* @var int
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
*/
private $id;
/**
* @var User
*
* @ORM\ManyToOne(targetEntity="App\Entity\User")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=true)
*/
private $user;
/**
* @var float
*
* @ORM\Column(name="rate", type="float", nullable=false)
* @Assert\GreaterThanOrEqual(0)
*/
private $rate = 0.00;
/**
* @var bool
*
* @ORM\Column(name="fixed", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $isFixed = false;
/**
* Get entry id, returns null for new entities which were not persisted.
*
* @return int|null
*/
public function getId(): ?int
{
return $this->id;
}
public function setUser(?User $user): self
{
$this->user = $user;
return $this;
}
public function getUser(): ?User
{
return $this->user;
}
public function setRate(float $rate): self
{
$this->rate = $rate;
return $this;
}
public function getRate(): float
{
return $this->rate;
}
public function isFixed(): bool
{
return $this->isFixed;
}
public function setIsFixed(bool $isFixed): self
{
$this->isFixed = $isFixed;
return $this;
}
}

View File

@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
interface RateInterface
{
public function getUser(): ?User;
public function getRate(): float;
public function isFixed(): bool;
public function getScore(): int;
}

View File

@@ -1,70 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
trait RatesTrait
{
/**
* @var float
*
* @ORM\Column(name="fixed_rate", type="float", nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="float", nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/**
* @return float
*/
public function getFixedRate(): ?float
{
return $this->fixedRate;
}
/**
* @param float $fixedRate
* @return self
*/
public function setFixedRate(?float $fixedRate)
{
$this->fixedRate = $fixedRate;
return $this;
}
/**
* @return float
*/
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
/**
* @param float $hourlyRate
* @return self
*/
public function setHourlyRate(?float $hourlyRate)
{
$this->hourlyRate = $hourlyRate;
return $this;
}
}

View File

@@ -130,8 +130,21 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/ */
private $rate = 0.00; private $rate = 0.00;
// keep the trait include exactly here, for placing the column at the correct position /**
use RatesTrait; * @var float
*
* @ORM\Column(name="fixed_rate", type="float", nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $fixedRate = null;
/**
* @var float
*
* @ORM\Column(name="hourly_rate", type="float", nullable=true)
* @Assert\GreaterThanOrEqual(0)
*/
private $hourlyRate = null;
/** /**
* @var bool * @var bool
@@ -452,6 +465,30 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return self::CATEGORY_WORK; return self::CATEGORY_WORK;
} }
public function getFixedRate(): ?float
{
return $this->fixedRate;
}
public function setFixedRate(?float $fixedRate): Timesheet
{
$this->fixedRate = $fixedRate;
return $this;
}
public function getHourlyRate(): ?float
{
return $this->hourlyRate;
}
public function setHourlyRate(?float $hourlyRate): Timesheet
{
$this->hourlyRate = $hourlyRate;
return $this;
}
/** /**
* @internal only here for symfony forms * @internal only here for symfony forms
* @return Collection|MetaTableTypeInterface[] * @return Collection|MetaTableTypeInterface[]

View File

@@ -95,19 +95,19 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) { if ($auth->isGranted('view_customer') || $auth->isGranted('view_teamlead_customer') || $auth->isGranted('view_team_customer')) {
$customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer')); $customers = new MenuItemModel('customer_admin', 'menu.admin_customer', 'admin_customer', [], $this->getIcon('customer'));
$customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'admin_customer_budget', 'admin_customer_edit', 'admin_customer_delete']); $customers->setChildRoutes(['admin_customer_create', 'admin_customer_permissions', 'customer_details', 'admin_customer_edit', 'admin_customer_delete']);
$menu->addChild($customers); $menu->addChild($customers);
} }
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) { if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project')); $projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'admin_project_budget', 'admin_project_edit', 'admin_project_delete']); $projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects); $menu->addChild($projects);
} }
if ($auth->isGranted('view_activity')) { if ($auth->isGranted('view_activity')) {
$activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $this->getIcon('activity')); $activities = new MenuItemModel('activity_admin', 'menu.admin_activity', 'admin_activity', [], $this->getIcon('activity'));
$activities->setChildRoutes(['admin_activity_create', 'admin_activity_budget', 'admin_activity_edit', 'admin_activity_delete']); $activities->setChildRoutes(['admin_activity_create', 'activity_details', 'admin_activity_edit', 'admin_activity_delete']);
$menu->addChild($activities); $menu->addChild($activities);
} }

View File

@@ -0,0 +1,72 @@
<?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\ActivityRate;
use App\Entity\Rate;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ActivityRateForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$currency = null;
if ($options['data']) {
/** @var ActivityRate $rate */
$rate = $options['data'];
if (null !== $rate->getActivity() && !$rate->getActivity()->isGlobal()) {
$currency = $rate->getActivity()->getProject()->getCustomer()->getCurrency();
}
}
$builder
->add('user', UserType::class, [
'required' => false,
])
->add('rate', MoneyType::class, [
'label' => 'label.rate',
'attr' => [
'autofocus' => 'autofocus'
],
'currency' => $currency,
])
->add('isFixed', YesNoType::class, [
'label' => 'label.fixedRate'
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ActivityRate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'expand_users' => true,
'csrf_token_id' => 'admin_customer_rate_edit',
'attr' => [
'data-form-event' => 'kimai.activityUpdate'
],
]);
}
}

View File

@@ -0,0 +1,71 @@
<?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\CustomerRate;
use App\Entity\Rate;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CustomerRateForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$currency = null;
if ($options['data']) {
/** @var CustomerRate $rate */
$rate = $options['data'];
if (null !== $customer = $rate->getCustomer()) {
$currency = $customer->getCurrency();
}
}
$builder
->add('user', UserType::class, [
'required' => false,
])
->add('rate', MoneyType::class, [
'label' => 'label.rate',
'attr' => [
'autofocus' => 'autofocus'
],
'currency' => $currency,
])
->add('isFixed', YesNoType::class, [
'label' => 'label.fixedRate'
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => CustomerRate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'expand_users' => true,
'csrf_token_id' => 'admin_customer_rate_edit',
'attr' => [
'data-form-event' => 'kimai.customerUpdate'
],
]);
}
}

View File

@@ -11,8 +11,6 @@ namespace App\Form;
use App\Form\Type\ColorPickerType; use App\Form\Type\ColorPickerType;
use App\Form\Type\DurationType; use App\Form\Type\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType; use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\YesNoType; use App\Form\Type\YesNoType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -26,12 +24,6 @@ trait EntityFormTrait
$currency = $options['currency']; $currency = $options['currency'];
$builder $builder
->add('color', ColorPickerType::class) ->add('color', ColorPickerType::class)
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
])
; ;
if ($options['include_budget']) { if ($options['include_budget']) {

View File

@@ -0,0 +1,71 @@
<?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\ProjectRate;
use App\Entity\Rate;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectRateForm extends AbstractType
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$currency = null;
if ($options['data']) {
/** @var ProjectRate $rate */
$rate = $options['data'];
if (null !== $customer = $rate->getProject()->getCustomer()) {
$currency = $customer->getCurrency();
}
}
$builder
->add('user', UserType::class, [
'required' => false,
])
->add('rate', MoneyType::class, [
'label' => 'label.rate',
'attr' => [
'autofocus' => 'autofocus'
],
'currency' => $currency,
])
->add('isFixed', YesNoType::class, [
'label' => 'label.fixedRate'
])
;
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectRate::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'expand_users' => true,
'csrf_token_id' => 'admin_project_rate_edit',
'attr' => [
'data-form-event' => 'kimai.projectUpdate'
],
]);
}
}

View File

@@ -29,12 +29,6 @@ class InvoiceModelActivityHydrator implements InvoiceModelHydrator
'activity.id' => $activity->getId(), 'activity.id' => $activity->getId(),
'activity.name' => $activity->getName(), 'activity.name' => $activity->getName(),
'activity.comment' => $activity->getComment(), 'activity.comment' => $activity->getComment(),
'activity.fixed_rate' => $formatter->getFormattedMoney($activity->getFixedRate(), $currency),
'activity.fixed_rate_nc' => $formatter->getFormattedMoney($activity->getFixedRate(), null),
'activity.fixed_rate_plain' => $activity->getFixedRate(),
'activity.hourly_rate' => $formatter->getFormattedMoney($activity->getHourlyRate(), $currency),
'activity.hourly_rate_nc' => $formatter->getFormattedMoney($activity->getHourlyRate(), null),
'activity.hourly_rate_plain' => $activity->getHourlyRate(),
]; ];
foreach ($activity->getVisibleMetaFields() as $metaField) { foreach ($activity->getVisibleMetaFields() as $metaField) {

View File

@@ -36,12 +36,6 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
'customer.country' => $customer->getCountry(), 'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(), 'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(), 'customer.comment' => $customer->getComment(),
'customer.fixed_rate' => $formatter->getFormattedMoney($customer->getFixedRate(), $currency),
'customer.fixed_rate_nc' => $formatter->getFormattedMoney($customer->getFixedRate(), null),
'customer.fixed_rate_plain' => $customer->getFixedRate(),
'customer.hourly_rate' => $formatter->getFormattedMoney($customer->getHourlyRate(), $currency),
'customer.hourly_rate_nc' => $formatter->getFormattedMoney($customer->getHourlyRate(), null),
'customer.hourly_rate_plain' => $customer->getHourlyRate(),
]; ];
foreach ($customer->getVisibleMetaFields() as $metaField) { foreach ($customer->getVisibleMetaFields() as $metaField) {

View File

@@ -33,12 +33,6 @@ class InvoiceModelProjectHydrator implements InvoiceModelHydrator
'project.start_date' => null !== $project->getStart() ? $formatter->getFormattedDateTime($project->getStart()) : '', 'project.start_date' => null !== $project->getStart() ? $formatter->getFormattedDateTime($project->getStart()) : '',
'project.end_date' => null !== $project->getEnd() ? $formatter->getFormattedDateTime($project->getEnd()) : '', 'project.end_date' => null !== $project->getEnd() ? $formatter->getFormattedDateTime($project->getEnd()) : '',
'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '', 'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '',
'project.fixed_rate' => $formatter->getFormattedMoney($project->getFixedRate(), $currency),
'project.fixed_rate_nc' => $formatter->getFormattedMoney($project->getFixedRate(), null),
'project.fixed_rate_plain' => $project->getFixedRate(),
'project.hourly_rate' => $formatter->getFormattedMoney($project->getHourlyRate(), $currency),
'project.hourly_rate_nc' => $formatter->getFormattedMoney($project->getHourlyRate(), null),
'project.hourly_rate_plain' => $project->getHourlyRate(),
'project.budget_money' => $formatter->getFormattedMoney($project->getBudget(), $currency), 'project.budget_money' => $formatter->getFormattedMoney($project->getBudget(), $currency),
'project.budget_money_nc' => $formatter->getFormattedMoney($project->getBudget(), null), 'project.budget_money_nc' => $formatter->getFormattedMoney($project->getBudget(), null),
'project.budget_money_plain' => $project->getBudget(), 'project.budget_money_plain' => $project->getBudget(),

View File

@@ -14,6 +14,11 @@ namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration; use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Schema;
/**
* Adds language and decimal_duration column to invoice template table
*
* @version 1.8
*/
final class Version20200204124425 extends AbstractMigration final class Version20200204124425 extends AbstractMigration
{ {
public function getDescription(): string public function getDescription(): string

View File

@@ -0,0 +1,71 @@
<?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;
/**
* Adds the rate table, which allows to define user specific rate rules
*
* @version 1.8
*/
final class Version20200205115243 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds the rate table, which allows to define user specific rate rules';
}
public function up(Schema $schema): void
{
$customerRates = $schema->createTable('kimai2_customers_rates');
$customerRates->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$customerRates->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => false]);
$customerRates->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => false]);
$customerRates->addColumn('rate', 'float', ['notnull' => true]);
$customerRates->addColumn('fixed', 'boolean', ['notnull' => true]);
$customerRates->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_82AB0AECA76ED395');
$customerRates->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_82AB0AEC9395C3F3');
$customerRates->addUniqueIndex(['user_id', 'customer_id'], 'UNIQ_82AB0AECA76ED3959395C3F3');
$customerRates->setPrimaryKey(['id']);
$projectRates = $schema->createTable('kimai2_projects_rates');
$projectRates->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$projectRates->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => false]);
$projectRates->addColumn('project_id', 'integer', ['length' => 11, 'notnull' => false]);
$projectRates->addColumn('rate', 'float', ['notnull' => true]);
$projectRates->addColumn('fixed', 'boolean', ['notnull' => true]);
$projectRates->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_41535D55A76ED395');
$projectRates->addForeignKeyConstraint('kimai2_projects', ['project_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_41535D55166D1F9C');
$projectRates->addUniqueIndex(['user_id', 'project_id'], 'UNIQ_41535D55A76ED395166D1F9C');
$projectRates->setPrimaryKey(['id']);
$activityRates = $schema->createTable('kimai2_activities_rates');
$activityRates->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$activityRates->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => false]);
$activityRates->addColumn('activity_id', 'integer', ['length' => 11, 'notnull' => false]);
$activityRates->addColumn('rate', 'float', ['notnull' => true]);
$activityRates->addColumn('fixed', 'boolean', ['notnull' => true]);
$activityRates->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_4A7F11BEA76ED395');
$activityRates->addForeignKeyConstraint('kimai2_activities', ['activity_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_4A7F11BE81C06096');
$activityRates->addUniqueIndex(['user_id', 'activity_id'], 'UNIQ_4A7F11BEA76ED39581C06096');
$activityRates->setPrimaryKey(['id']);
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_activities_rates');
$schema->dropTable('kimai2_projects_rates');
$schema->dropTable('kimai2_customers_rates');
}
}

View File

@@ -0,0 +1,72 @@
<?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;
/**
* Migrates the data from entity tables to user specific rate tables
*
* @version 1.8
*/
final class Version20200205115244 extends AbstractMigration
{
public function getDescription(): string
{
return 'Migrates the data from entity tables to user specific rate tables';
}
public function up(Schema $schema): void
{
$migrates = [
['kimai2_activities', 'activity_id', 'kimai2_activities_rates'],
['kimai2_projects', 'project_id', 'kimai2_projects_rates'],
['kimai2_customers', 'customer_id', 'kimai2_customers_rates'],
];
foreach ($migrates as $migrateOpts) {
$tableName = $migrateOpts[0];
$fieldName = $migrateOpts[1];
$targetTable = $migrateOpts[2];
$rules = $this->connection->prepare(
'SELECT id, fixed_rate, hourly_rate FROM ' . $tableName . ' WHERE fixed_rate IS NOT NULL OR hourly_rate IS NOT NULL'
);
$rules->execute();
foreach ($rules->fetchAll() as $rateRule) {
$isFixed = $rateRule['fixed_rate'] !== null;
$rate = $rateRule['fixed_rate'] ?? $rateRule['hourly_rate'];
$params = ['user_id' => null, $fieldName => $rateRule['id'], 'rate' => $rate, 'fixed' => $isFixed];
$this->connection->insert($targetTable, $params, ['fixed' => \PDO::PARAM_BOOL]);
}
}
$schema->getTable('kimai2_customers')->dropColumn('fixed_rate')->dropColumn('hourly_rate');
$schema->getTable('kimai2_projects')->dropColumn('fixed_rate')->dropColumn('hourly_rate');
$schema->getTable('kimai2_activities')->dropColumn('fixed_rate')->dropColumn('hourly_rate');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE kimai2_customers ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_customers ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_projects ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_projects ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_activities ADD COLUMN fixed_rate NUMERIC(10, 2) DEFAULT NULL');
$this->addSql('ALTER TABLE kimai2_activities ADD COLUMN hourly_rate NUMERIC(10, 2) DEFAULT NULL');
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
class ActivityRateRepository extends EntityRepository
{
public function saveRate(ActivityRate $rate)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(ActivityRate $rate)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @param Activity $activity
* @return ActivityRate[]
*/
public function getRatesForActivity(Activity $activity): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, a')
->from(ActivityRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.activity', 'a')
->andWhere(
$qb->expr()->eq('r.activity', ':activity')
)
->addOrderBy('u.alias')
->setParameter('activity', $activity)
;
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Customer;
use App\Entity\CustomerRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
class CustomerRateRepository extends EntityRepository
{
public function saveRate(CustomerRate $rate)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(CustomerRate $rate)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @param Customer $customer
* @return CustomerRate[]
*/
public function getRatesForCustomer(Customer $customer): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, c')
->from(CustomerRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.customer', 'c')
->andWhere(
$qb->expr()->eq('r.customer', ':customer')
)
->addOrderBy('u.alias')
->setParameter('customer', $customer)
;
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\Project;
use App\Entity\ProjectRate;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
class ProjectRateRepository extends EntityRepository
{
public function saveRate(ProjectRate $rate)
{
$entityManager = $this->getEntityManager();
$entityManager->persist($rate);
$entityManager->flush();
}
public function deleteRate(ProjectRate $rate)
{
$em = $this->getEntityManager();
$em->beginTransaction();
try {
$em->remove($rate);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @param Project $project
* @return ProjectRate[]
*/
public function getRatesForProject(Project $project): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, p')
->from(ProjectRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.project', 'p')
->andWhere(
$qb->expr()->eq('r.project', ':project')
)
->addOrderBy('u.alias')
->setParameter('project', $project)
;
return $qb->getQuery()->getResult();
}
}

View File

@@ -10,6 +10,10 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\CustomerRate;
use App\Entity\ProjectRate;
use App\Entity\RateInterface;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Model\Statistic\Day; use App\Model\Statistic\Day;
@@ -834,4 +838,73 @@ class TimesheetRepository extends EntityRepository
return $field; return $field;
} }
/**
* @param Timesheet $timesheet
* @return RateInterface[]
*/
public function findMatchingRates(Timesheet $timesheet): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, a')
->from(ActivityRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.activity', 'a')
->andWhere(
$qb->expr()->orX(
$qb->expr()->eq('r.user', ':user'),
$qb->expr()->isNull('r.user')
),
$qb->expr()->orX(
$qb->expr()->eq('r.activity', ':activity'),
$qb->expr()->isNull('r.activity')
)
)
->setParameter('user', $timesheet->getUser())
->setParameter('activity', $timesheet->getActivity())
;
$results = $qb->getQuery()->getResult();
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, p')
->from(ProjectRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.project', 'p')
->andWhere(
$qb->expr()->orX(
$qb->expr()->eq('r.user', ':user'),
$qb->expr()->isNull('r.user')
),
$qb->expr()->orX(
$qb->expr()->eq('r.project', ':project'),
$qb->expr()->isNull('r.project')
)
)
->setParameter('user', $timesheet->getUser())
->setParameter('project', $timesheet->getProject())
;
$results = array_merge($results, $qb->getQuery()->getResult());
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('r, u, c')
->from(CustomerRate::class, 'r')
->leftJoin('r.user', 'u')
->leftJoin('r.customer', 'c')
->andWhere(
$qb->expr()->orX(
$qb->expr()->eq('r.user', ':user'),
$qb->expr()->isNull('r.user')
),
$qb->expr()->orX(
$qb->expr()->eq('r.customer', ':customer'),
$qb->expr()->isNull('r.customer')
)
)
->setParameter('user', $timesheet->getUser())
->setParameter('customer', $timesheet->getProject()->getCustomer())
;
$results = array_merge($results, $qb->getQuery()->getResult());
return $results;
}
} }

View File

@@ -9,8 +9,11 @@
namespace App\Timesheet\Calculator; namespace App\Timesheet\Calculator;
use App\Entity\Rate;
use App\Entity\RateInterface;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\CalculatorInterface; use App\Timesheet\CalculatorInterface;
use App\Timesheet\Util; use App\Timesheet\Util;
@@ -22,15 +25,16 @@ class RateCalculator implements CalculatorInterface
/** /**
* @var array * @var array
*/ */
protected $rates; private $rates;
/** /**
* RateCalculator constructor. * @var TimesheetRepository
* @param array $rates
*/ */
public function __construct(array $rates) private $repository;
public function __construct(array $rates, TimesheetRepository $repository)
{ {
$this->rates = $rates; $this->rates = $rates;
$this->repository = $repository;
} }
/** /**
@@ -44,7 +48,21 @@ class RateCalculator implements CalculatorInterface
return; return;
} }
$fixedRate = $this->findFixedRate($record); $fixedRate = $record->getFixedRate();
$hourlyRate = $record->getHourlyRate();
if (null === $fixedRate && null === $hourlyRate) {
$rate = $this->getBestFittingRate($record);
if (null !== $rate) {
if ($rate->isFixed()) {
$fixedRate = $rate->getRate();
} else {
$hourlyRate = $rate->getRate();
}
}
}
if (null !== $fixedRate) { if (null !== $fixedRate) {
$record->setRate($fixedRate); $record->setRate($fixedRate);
$record->setFixedRate($fixedRate); $record->setFixedRate($fixedRate);
@@ -52,74 +70,40 @@ class RateCalculator implements CalculatorInterface
return; return;
} }
$hourlyRate = $this->findHourlyRate($record); if (null === $hourlyRate) {
$hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0.00);
}
$factor = $this->getRateFactor($record); $factor = $this->getRateFactor($record);
$hourlyRate = (float) ($hourlyRate * $factor); $hourlyRate = (float) ($hourlyRate * $factor);
$rate = 0; $totalRate = 0;
if (null !== $record->getDuration()) { if (null !== $record->getDuration()) {
$rate = Util::calculateRate($hourlyRate, $record->getDuration()); $totalRate = Util::calculateRate($hourlyRate, $record->getDuration());
} }
$record->setHourlyRate($hourlyRate); $record->setHourlyRate($hourlyRate);
$record->setRate($rate); $record->setRate($totalRate);
} }
/** private function getBestFittingRate(Timesheet $timesheet): ?RateInterface
* @param Timesheet $record
* @return float
*/
protected function findHourlyRate(Timesheet $record)
{ {
if (null !== $record->getHourlyRate()) { $rates = $this->repository->findMatchingRates($timesheet);
return $record->getHourlyRate(); /** @var RateInterface[] $sorted */
$sorted = [];
foreach ($rates as $rate) {
$score = $rate->getScore();
if (null !== $rate->getUser() && $timesheet->getUser() === $rate->getUser()) {
++$score;
} }
$activity = $record->getActivity(); $sorted[$score] = $rate;
if (null !== $activity->getHourlyRate()) {
return $activity->getHourlyRate();
} }
$project = $record->getProject(); if (!empty($sorted)) {
if (null !== $project) { ksort($sorted);
if (null !== $project->getHourlyRate()) {
return $project->getHourlyRate();
}
$customer = $project->getCustomer(); return end($sorted);
if (null !== $customer->getHourlyRate()) {
return $customer->getHourlyRate();
}
}
return (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0);
}
/**
* @param Timesheet $record
* @return float|null
*/
protected function findFixedRate(Timesheet $record)
{
if (null !== $record->getFixedRate()) {
return $record->getFixedRate();
}
$activity = $record->getActivity();
if (null !== $activity->getFixedRate()) {
return $activity->getFixedRate();
}
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getFixedRate()) {
return $project->getFixedRate();
}
$customer = $project->getCustomer();
if (null !== $customer->getFixedRate()) {
return $customer->getFixedRate();
}
} }
return null; return null;

View File

@@ -18,6 +18,9 @@
{% set actions = {} %} {% set actions = {} %}
{% if activity.id is not empty %} {% if activity.id is not empty %}
{% if view != 'details' and is_granted('view', activity) %}
{% set actions = actions|merge({'details': path('activity_details', {'id': activity.id})}) %}
{% endif %}
{% if is_granted('edit', activity) %} {% if is_granted('edit', activity) %}
{% set class = '' %} {% set class = '' %}
{% if view != 'edit' %} {% if view != 'edit' %}
@@ -25,9 +28,6 @@
{% endif %} {% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_activity_edit', {'id': activity.id}), 'class': class}}) %} {% set actions = actions|merge({'edit': {'url': path('admin_activity_edit', {'id': activity.id}), 'class': class}}) %}
{% endif %} {% endif %}
{% if is_granted('budget', activity) %}
{% set actions = actions|merge({'report': {'url': path('admin_activity_budget', {'id': activity.id})}}) %}
{% endif %}
{% if actions|length > 0 %} {% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %} {% set actions = actions|merge({'divider': null}) %}
{% endif %} {% endif %}

View File

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

View File

@@ -0,0 +1,149 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "activity/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.activity(activity, 'details') }}{% endblock %}
{% block main %}
{% set can_edit = is_granted('edit', activity) %}
{% set currency = null %}
{% if activity.project is not null %}
{% set currency = activity.project.customer.currency %}
{% endif %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as customerActions %}
{% import "project/actions.html.twig" as projectActions %}
{% block box_attributes %}id="activity_details_box"{% endblock %}
{% block box_title %}
{{ widgets.label_activity(activity) }}
{% endblock %}
{% block box_tools %}
{% if can_edit %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_activity_edit', {'id': activity.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'action.edit'|trans }}"><i class="{{ 'edit'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_body %}
{% if activity.comment is not empty %}
{{ activity.comment|comment2html(true) }}
{% endif %}
<table class="table table-hover">
{% if not activity.visible %}
<tr>
<th>{{ 'label.visible'|trans }}</th>
<td>
{{ widgets.label_boolean(activity.visible) }}
</td>
</tr>
{% endif %}
{% if not activity.global %}
<tr>
<th>{{ 'label.customer'|trans }}</th>
<td>
{{ widgets.label_customer(activity.project.customer) }}
{% if activity.project.customer.teams|length == 0 %}
{{ widgets.icon('unlocked') }}
{% endif %}
&nbsp;
{{ customerActions.customer(activity.project.customer, 'custom') }}
</td>
</tr>
<tr>
<th>{{ 'label.project'|trans }}</th>
<td>
{{ widgets.label_project(activity.project) }}
{% if activity.project.teams|length == 0 %}
{{ widgets.icon('unlocked') }}
{% endif %}
&nbsp;
{{ projectActions.project(activity.project, 'custom') }}
</td>
</tr>
{% endif %}
{% for metaField in activity.visibleMetaFields %}
<tr>
<th>{{ metaField.label }}</th>
<td>{{ widgets.form_type_value(metaField.type, metaField.value, activity) }}</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endembed %}
{% if stats is not null %}
{{ include('activity/embed_budget.html.twig', {'activity': activity, 'stats': stats}) }}
{% endif %}
{% if can_edit %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="activity_rates_box"{% endblock %}
{% block box_title %}
{{ 'rates.title'|trans }}
{% endblock %}
{% block box_tools %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_activity_rate_add', {'id': activity.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'create'|trans }}"><i class="{{ 'create'|icon }}"></i></a>
{% endblock %}
{% block box_body %}
{% if rates is empty %}
{{ 'rates.empty'|trans }}
{% else %}
<table class="table dataTable" >
<thead>
<tr>
<th>
{{ 'label.user'|trans }}
</th>
<th>
{{ 'label.hourlyRate'|trans }}
</th>
<th>
{{ 'label.fixedRate'|trans }}
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for rate in rates %}
<tr>
<td>
{% if rate.user is not null %}
{{ widgets.user_avatar(rate.user) }}
{% else %}
&ndash;
{% endif %}
</td>
<td>
{% if not rate.fixed %}
{{ rate.rate|money(currency) }}
{% endif %}
</td>
<td>
{% if rate.fixed %}
{{ rate.rate|money(currency) }}
{% endif %}
</td>
<td class="actions">
<a href="{{ path('admin_activity_rate_delete', {'id': activity.id, 'rate': rate.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% endembed %}
{% endif %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.activityUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.projectUpdate');
});
</script>
{% endblock %}

View File

@@ -25,16 +25,6 @@
{{ form_row(form.comment) }} {{ form_row(form.comment) }}
{{ form_row(form.customer) }} {{ form_row(form.customer) }}
{{ form_row(form.project) }} {{ form_row(form.project) }}
<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>
{% if form.budget is defined %} {% if form.budget is defined %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -45,13 +35,9 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</fieldset>
{{ form_row(form.visible) }} {{ form_row(form.visible) }}
{% if form.metaFields is defined and form.metaFields is not empty %} {% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset>
{#<legend>Custom fields</legend>#}
{{ form_row(form.metaFields) }} {{ form_row(form.metaFields) }}
</fieldset>
{% endif %} {% endif %}
{{ form_widget(form) }} {{ form_widget(form) }}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,33 @@
{% 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 = {
'%activity%': '<strong>' ~ activity.name ~ '</strong>',
'%project%': '<strong>-</strong>',
'%customer%': '<strong>-</strong>',
'%records%': '<strong>' ~ stats.recordAmount ~ '</strong>',
'%duration%': '<strong>' ~ stats.recordDuration|duration ~ '</strong>'
} %}
{% if activity.project is not null %}
{% set params = params|merge({
'%project%': '<strong>' ~ activity.project.name ~ '</strong>',
'%customer%': '<strong>' ~ activity.project.customer.name ~ '</strong>',
}) %}
{% endif %}
<p>
{{ 'admin_activity.short_stats'|trans(params)|raw }}
</p>
{% set currency = null %}
{% if activity.project is not null %}
{% set currency = activity.project.customer.currency %}
{% endif %}
{{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }}
{{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -42,7 +42,7 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate'}) }}
{% for entry in entries %} {% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_activity_edit', {'id': entry.id}) }}"{% endif %}> <tr{% if is_granted('view', entry) %} class="alternative-link open-edit" data-href="{{ path('activity_details', {'id': entry.id}) }}"{% endif %}>
<td>{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td> <td>{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{# only none-global activities have a project and customer assigned #} {# only none-global activities have a project and customer assigned #}

View File

@@ -0,0 +1,13 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "activity/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.activity(activity, 'rates') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': activity.name,
'form': form,
'back': path('admin_activity')
}) }}
{% endblock %}

View File

@@ -108,28 +108,6 @@
</tr> </tr>
{% endif %} {% endif %}
{% endif %} {% 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 %} {% for metaField in customer.visibleMetaFields %}
<tr> <tr>
<th>{{ metaField.label }}</th> <th>{{ metaField.label }}</th>
@@ -146,6 +124,67 @@
{{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }} {{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }}
{% endif %} {% endif %}
{% if can_edit %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="customer_rates_box"{% endblock %}
{% block box_title %}
{{ 'rates.title'|trans }}
{% endblock %}
{% block box_tools %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_customer_rate_add', {'id': customer.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'create'|trans }}"><i class="{{ 'create'|icon }}"></i></a>
{% endblock %}
{% block box_body %}
{% if rates is empty %}
{{ 'rates.empty'|trans }}
{% else %}
<table class="table dataTable" >
<thead>
<tr>
<th>
{{ 'label.user'|trans }}
</th>
<th>
{{ 'label.hourlyRate'|trans }}
</th>
<th>
{{ 'label.fixedRate'|trans }}
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for rate in rates %}
<tr>
<td>
{% if rate.user is not null %}
{{ widgets.user_avatar(rate.user) }}
{% else %}
&ndash;
{% endif %}
</td>
<td>
{% if not rate.fixed %}
{{ rate.rate|money(customer.currency) }}
{% endif %}
</td>
<td>
{% if rate.fixed %}
{{ rate.rate|money(customer.currency) }}
{% endif %}
</td>
<td class="actions">
<a href="{{ path('admin_customer_rate_delete', {'id': customer.id, 'rate': rate.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% endembed %}
{% endif %}
{% if teams is not null %} {% if teams is not null %}
{% set options = {'teams': teams, 'team': team} %} {% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', customer) %} {% if is_granted('permissions', customer) %}

View File

@@ -68,14 +68,6 @@
{{ form_row(form.fax) }} {{ form_row(form.fax) }}
</div> </div>
</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 %} {% if form.budget is defined %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">

View File

@@ -33,6 +33,9 @@
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}
<script type="text/javascript"> <script type="text/javascript">
{% set eventName = app.request.xmlHttpRequest ? 'kimai.reloadPage' : 'kimai.initialized' %}
document.addEventListener('{{ eventName }}', function() {
KimaiPaginatedBoxWidget.create('#project_list_box'); KimaiPaginatedBoxWidget.create('#project_list_box');
});
</script> </script>
{% endif %} {% endif %}

View File

@@ -1,5 +1,4 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} {% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as actions %} {% import "customer/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %} {% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "customer/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.customer(customer, 'rates') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': customer.name,
'form': form,
'back': path('admin_customer')
}) }}
{% endblock %}

View File

@@ -1,23 +1,23 @@
{% if form_theme is defined %} {% if form_theme is defined %}
{% form_theme form form_theme %} {% form_theme form form_theme %}
{% endif %} {% endif %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'boxtype': 'primary'} %} <div class="box box-primary">
{% block box_before %}
{% block form_before %}{% endblock %} {% block form_before %}{% endblock %}
{{ form_start(form) }} {{ form_start(form) }}
{% endblock %} <div class="box-header with-border">
{% block box_title %} <h3 class="box-title">
{{ title }} {{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %} {% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a> <a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %} {% endif %}
{% endblock %} </h3>
{% block box_body %} </div>
<div class="box-body">
{% block form_body %} {% block form_body %}
{{ form_widget(form) }} {{ form_widget(form) }}
{% endblock %} {% endblock %}
{% endblock %} </div>
{% block box_footer %} <div class="box-footer">
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" /> <input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{% if back|default(false) %} {% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a> <a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
@@ -25,9 +25,7 @@
{% if reset|default(true) %} {% if reset|default(true) %}
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" /> <input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
{% endif %} {% endif %}
{% endblock %} </div>
{% block box_after %}
{{ form_end(form) }} {{ form_end(form) }}
{% block form_after %}{% endblock %} {% block form_after %}{% endblock %}
{% endblock %} </div>
{% endembed %}

View File

@@ -26,7 +26,7 @@
<p>{{ 'team.visibility_restricted'|trans({}, 'teams') }}</p> <p>{{ 'team.visibility_restricted'|trans({}, 'teams') }}</p>
{{ widgets.team_list(teams) }} {{ widgets.team_list(teams) }}
{% else %} {% else %}
<p>{{ empty_message|default('team.visibility_global')|trans({}, 'teams') }}</p> {{ empty_message|default('team.visibility_global')|trans({}, 'teams') }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}

View File

@@ -61,11 +61,12 @@
{% if model.calculator is empty or model.calculator.entries is empty %} {% if model.calculator is empty or model.calculator.entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }} {{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %} {% else %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set entries = model.calculator.entries %} {% set entries = model.calculator.entries %}
{{ tables.datatable_header(tableName, columns, query, {}) }} {{ tables.datatable_header(tableName, columns, query, {}) }}
{% for entry in entries %} {% for entry in entries %}
{% set amount = entry.amount %} {% set amount = entry.amount %}
{% set duration = entry.duration|duration %} {% set duration = entry.duration|duration(isDecimal) %}
{% set rate = 0 %} {% set rate = 0 %}
{% if entry.fixedRate is not null %} {% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %} {% set rate = entry.fixedRate %}

View File

@@ -82,28 +82,6 @@
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% 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 %} {% for metaField in project.visibleMetaFields %}
<tr> <tr>
<th>{{ metaField.label }}</th> <th>{{ metaField.label }}</th>
@@ -120,6 +98,67 @@
{{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }} {{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }}
{% endif %} {% endif %}
{% if can_edit %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_attributes %}id="project_rates_box"{% endblock %}
{% block box_title %}
{{ 'rates.title'|trans }}
{% endblock %}
{% block box_tools %}
<a class="modal-ajax-form open-edit btn btn-box-tool" data-href="{{ path('admin_project_rate_add', {'id': project.id}) }}" data-toggle="tooltip" data-placement="top" title="{{ 'create'|trans }}"><i class="{{ 'create'|icon }}"></i></a>
{% endblock %}
{% block box_body %}
{% if rates is empty %}
{{ 'rates.empty'|trans }}
{% else %}
<table class="table dataTable" >
<thead>
<tr>
<th>
{{ 'label.user'|trans }}
</th>
<th>
{{ 'label.hourlyRate'|trans }}
</th>
<th>
{{ 'label.fixedRate'|trans }}
</th>
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% for rate in rates %}
<tr>
<td>
{% if rate.user is not null %}
{{ widgets.user_avatar(rate.user) }}
{% else %}
&ndash;
{% endif %}
</td>
<td>
{% if not rate.fixed %}
{{ rate.rate|money(project.customer.currency) }}
{% endif %}
</td>
<td>
{% if rate.fixed %}
{{ rate.rate|money(project.customer.currency) }}
{% endif %}
</td>
<td class="actions">
<a href="{{ path('admin_project_rate_delete', {'id': project.id, 'rate': rate.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% endblock %}
{% endembed %}
{% endif %}
{% if teams is not null%} {% if teams is not null%}
{% set options = {'teams': teams, 'team': team} %} {% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', project) %} {% if is_granted('permissions', project) %}

View File

@@ -40,16 +40,6 @@
{{ form_row(form.end) }} {{ form_row(form.end) }}
</div> </div>
</div> </div>
<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>
{% if form.budget is defined %} {% if form.budget is defined %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -60,13 +50,9 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
</fieldset>
{{ form_row(form.visible) }} {{ form_row(form.visible) }}
{% if form.metaFields is defined and form.metaFields is not empty %} {% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset>
{#<legend>Custom fields</legend>#}
{{ form_row(form.metaFields) }} {{ form_row(form.metaFields) }}
</fieldset>
{% endif %} {% endif %}
{{ form_widget(form) }} {{ form_widget(form) }}
{% endblock %} {% endblock %}

View File

@@ -24,6 +24,9 @@
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}
<script type="text/javascript"> <script type="text/javascript">
{% set eventName = app.request.xmlHttpRequest ? 'kimai.reloadPage' : 'kimai.initialized' %}
document.addEventListener('{{ eventName }}', function() {
KimaiPaginatedBoxWidget.create('#activity_list_box'); KimaiPaginatedBoxWidget.create('#activity_list_box');
});
</script> </script>
{% endif %} {% endif %}

View File

@@ -1,5 +1,4 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} {% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "project/actions.html.twig" as actions %} {% import "project/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %} {% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "project/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.project(project, 'rates') }}{% endblock %}
{% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', {
'title': project.name,
'form': form,
'back': path('admin_project')
}) }}
{% endblock %}

View File

@@ -285,7 +285,7 @@ class ActivityControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle' 'id', 'name', 'visible', 'project', 'color', 'metaFields', 'parentTitle'
]; ];
if ($full) { if ($full) {

View File

@@ -230,7 +230,7 @@ class CustomerControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'visible', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'teams' 'id', 'name', 'visible', 'color', 'metaFields', 'teams'
]; ];
if ($full) { if ($full) {

View File

@@ -152,7 +152,7 @@ class ProjectControllerTest extends APIControllerBaseTest
$data = [ $data = [
'name' => 'foo', 'name' => 'foo',
'customer' => 1, 'customer' => 1,
'visible' => true 'visible' => true,
]; ];
$this->request($client, '/api/projects', 'POST', [], json_encode($data)); $this->request($client, '/api/projects', 'POST', [], json_encode($data));
$response = $client->getResponse(); $response = $client->getResponse();
@@ -283,7 +283,7 @@ class ProjectControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true) protected function assertStructure(array $result, $full = true)
{ {
$expectedKeys = [ $expectedKeys = [
'id', 'name', 'visible', 'customer', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle', 'start', 'end', 'teams' 'id', 'name', 'visible', 'customer', 'color', 'metaFields', 'parentTitle', 'start', 'end', 'teams'
]; ];
if ($full) { if ($full) {

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Controller;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta; use App\Entity\ActivityMeta;
use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures; use App\Tests\DataFixtures\ActivityFixtures;
@@ -67,7 +68,7 @@ class ActivityControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_activity_admin', 5); $this->assertDataTableRowCount($client, 'datatable_activity_admin', 5);
} }
public function testBudgetAction() public function testDetailsAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */ /** @var EntityManager $em */
@@ -79,9 +80,70 @@ class ActivityControllerTest extends ControllerBaseTest
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN)); $fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$this->importFixture($client, $fixture); $this->importFixture($client, $fixture);
$project = $em->getRepository(Project::class)->find(1);
$fixture = new ActivityFixtures();
$fixture->setAmount(6); // to trigger a second page
$fixture->setProjects([$project]);
$this->importFixture($client, $fixture);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/budget'); $this->assertAccessIsGranted($client, '/admin/activity/1/details');
self::assertHasProgressbar($client); self::assertHasProgressbar($client);
$node = $client->getCrawler()->filter('div.box#activity_details_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#activity_rates_box');
self::assertEquals(1, $node->count());
}
public function testAddRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/rate');
$form = $client->getCrawler()->filter('form[name=activity_rate_form]')->form();
$client->submit($form, [
'activity_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#activity_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
}
public function testDeleteRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/activity/1/rate');
$form = $client->getCrawler()->filter('form[name=activity_rate_form]')->form();
$client->submit($form, [
'activity_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#activity_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
$node = $client->getCrawler()->filter('div.box#activity_rates_box table.dataTable tbody tr td.actions a');
self::assertEquals(1, $node->count());
$url = $node->attr('href');
$client->request('GET', $url);
$this->assertIsRedirect($client, $this->createUrl('/admin/activity/1/details'));
$node = $client->getCrawler()->filter('div.box#activity_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(0, $node->count());
} }
public function testCreateAction() public function testCreateAction()

View File

@@ -87,6 +87,56 @@ class CustomerControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool'); $node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
self::assertEquals(2, $node->count()); self::assertEquals(2, $node->count());
$node = $client->getCrawler()->filter('div.box#customer_rates_box');
self::assertEquals(1, $node->count());
}
public function testAddRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/rate');
$form = $client->getCrawler()->filter('form[name=customer_rate_form]')->form();
$client->submit($form, [
'customer_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#customer_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#customer_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
}
public function testDeleteRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/customer/1/rate');
$form = $client->getCrawler()->filter('form[name=customer_rate_form]')->form();
$client->submit($form, [
'customer_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#customer_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#customer_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
$node = $client->getCrawler()->filter('div.box#customer_rates_box table.dataTable tbody tr td.actions a');
self::assertEquals(1, $node->count());
$url = $node->attr('href');
$client->request('GET', $url);
$this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
$node = $client->getCrawler()->filter('div.box#customer_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(0, $node->count());
} }
public function testAddCommentAction() public function testAddCommentAction()

View File

@@ -106,6 +106,56 @@ class ProjectControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool'); $node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
self::assertEquals(2, $node->count()); self::assertEquals(2, $node->count());
$node = $client->getCrawler()->filter('div.box#project_rates_box');
self::assertEquals(1, $node->count());
}
public function testAddRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/rate');
$form = $client->getCrawler()->filter('form[name=project_rate_form]')->form();
$client->submit($form, [
'project_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#project_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#project_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
}
public function testDeleteRateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/admin/project/1/rate');
$form = $client->getCrawler()->filter('form[name=project_rate_form]')->form();
$client->submit($form, [
'project_rate_form' => [
'user' => null,
'rate' => 123.45,
]
]);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$client->followRedirect();
$node = $client->getCrawler()->filter('div.box#project_rates_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#project_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(1, $node->count());
self::assertStringContainsString('123.45', $node->text(null, true));
$node = $client->getCrawler()->filter('div.box#project_rates_box table.dataTable tbody tr td.actions a');
self::assertEquals(1, $node->count());
$url = $node->attr('href');
$client->request('GET', $url);
$this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
$node = $client->getCrawler()->filter('div.box#project_rates_box table.dataTable tbody tr:not(.summary)');
self::assertEquals(0, $node->count());
} }
public function testAddCommentAction() public function testAddCommentAction()

View File

@@ -33,7 +33,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
$result = $client->getCrawler()->filter('section.content div.box.box-primary'); $result = $client->getCrawler()->filter('section.content div.box.box-primary');
$this->assertEquals(count($expectedForms), count($result)); $this->assertEquals(count($expectedForms), count($result));
$result = $client->getCrawler()->filter('section.content form div.box.box-primary'); $result = $client->getCrawler()->filter('section.content div.box.box-primary form');
$this->assertEquals(count($expectedForms), count($result)); $this->assertEquals(count($expectedForms), count($result));
foreach ($expectedForms as $formConfig) { foreach ($expectedForms as $formConfig) {

View File

@@ -0,0 +1,55 @@
<?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\Activity;
use App\Entity\ActivityRate;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\ActivityRate
* @covers \App\Entity\Rate
*/
class ActivityRateTest extends TestCase
{
public function testDefaultValues()
{
$sut = new ActivityRate();
self::assertNull($sut->getId());
self::assertEquals(0.00, $sut->getRate());
self::assertNull($sut->getActivity());
self::assertNull($sut->getUser());
self::assertEquals(5, $sut->getScore());
self::assertFalse($sut->isFixed());
}
public function testSetterAndGetter()
{
$sut = new ActivityRate();
self::assertInstanceOf(ActivityRate::class, $sut->setIsFixed(true));
self::assertTrue($sut->isFixed());
self::assertInstanceOf(ActivityRate::class, $sut->setRate(12.34));
self::assertEquals(12.34, $sut->getRate());
$user = new User();
$user->setAlias('foo');
$user->setUsername('bar');
self::assertInstanceOf(ActivityRate::class, $sut->setUser($user));
self::assertSame($user, $sut->getUser());
$entity = new Activity();
$entity->setName('foo');
self::assertInstanceOf(ActivityRate::class, $sut->setActivity($entity));
self::assertSame($entity, $sut->getActivity());
}
}

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Entity;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityMeta; use App\Entity\ActivityMeta;
use App\Entity\Project;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -27,8 +28,7 @@ class ActivityTest extends TestCase
$this->assertNull($sut->getName()); $this->assertNull($sut->getName());
$this->assertNull($sut->getComment()); $this->assertNull($sut->getComment());
$this->assertTrue($sut->getVisible()); $this->assertTrue($sut->getVisible());
$this->assertNull($sut->getFixedRate()); $this->assertTrue($sut->isGlobal());
$this->assertNull($sut->getHourlyRate());
$this->assertNull($sut->getColor()); $this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget()); $this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget()); $this->assertEquals(0, $sut->getTimeBudget());
@@ -53,17 +53,15 @@ class ActivityTest extends TestCase
$this->assertInstanceOf(Activity::class, $sut->setColor('#fffccc')); $this->assertInstanceOf(Activity::class, $sut->setColor('#fffccc'));
$this->assertEquals('#fffccc', $sut->getColor()); $this->assertEquals('#fffccc', $sut->getColor());
$this->assertInstanceOf(Activity::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Activity::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate());
$this->assertInstanceOf(Activity::class, $sut->setBudget(12345.67)); $this->assertInstanceOf(Activity::class, $sut->setBudget(12345.67));
$this->assertEquals(12345.67, $sut->getBudget()); $this->assertEquals(12345.67, $sut->getBudget());
$this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321)); $this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget()); $this->assertEquals(937321, $sut->getTimeBudget());
$this->assertTrue($sut->isGlobal());
$this->assertInstanceOf(Activity::class, $sut->setProject(new Project()));
$this->assertFalse($sut->isGlobal());
} }
public function testMetaFields() public function testMetaFields()

View File

@@ -0,0 +1,55 @@
<?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\Customer;
use App\Entity\CustomerRate;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\CustomerRate
* @covers \App\Entity\Rate
*/
class CustomerRateTest extends TestCase
{
public function testDefaultValues()
{
$sut = new CustomerRate();
self::assertNull($sut->getId());
self::assertEquals(0.00, $sut->getRate());
self::assertNull($sut->getCustomer());
self::assertNull($sut->getUser());
self::assertEquals(1, $sut->getScore());
self::assertFalse($sut->isFixed());
}
public function testSetterAndGetter()
{
$sut = new CustomerRate();
self::assertInstanceOf(CustomerRate::class, $sut->setIsFixed(true));
self::assertTrue($sut->isFixed());
self::assertInstanceOf(CustomerRate::class, $sut->setRate(12.34));
self::assertEquals(12.34, $sut->getRate());
$user = new User();
$user->setAlias('foo');
$user->setUsername('bar');
self::assertInstanceOf(CustomerRate::class, $sut->setUser($user));
self::assertSame($user, $sut->getUser());
$entity = new Customer();
$entity->setName('foo');
self::assertInstanceOf(CustomerRate::class, $sut->setCustomer($entity));
self::assertSame($entity, $sut->getCustomer());
}
}

View File

@@ -43,8 +43,6 @@ class CustomerTest extends TestCase
self::assertNull($sut->getHomepage()); self::assertNull($sut->getHomepage());
self::assertNull($sut->getTimezone()); self::assertNull($sut->getTimezone());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getHourlyRate());
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertEquals(0.0, $sut->getBudget()); self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget()); self::assertEquals(0, $sut->getTimeBudget());
@@ -92,12 +90,6 @@ class CustomerTest extends TestCase
self::assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com')); self::assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
self::assertEquals('https://www.example.com', $sut->getHomepage()); self::assertEquals('https://www.example.com', $sut->getHomepage());
self::assertInstanceOf(Customer::class, $sut->setFixedRate(13.47));
self::assertEquals(13.47, $sut->getFixedRate());
self::assertInstanceOf(Customer::class, $sut->setHourlyRate(99));
self::assertEquals(99, $sut->getHourlyRate());
self::assertInstanceOf(Customer::class, $sut->setBudget(12345.67)); self::assertInstanceOf(Customer::class, $sut->setBudget(12345.67));
self::assertEquals(12345.67, $sut->getBudget()); self::assertEquals(12345.67, $sut->getBudget());

View File

@@ -0,0 +1,55 @@
<?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\Project;
use App\Entity\ProjectRate;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\ProjectRate
* @covers \App\Entity\Rate
*/
class ProjectRateTest extends TestCase
{
public function testDefaultValues()
{
$sut = new ProjectRate();
self::assertNull($sut->getId());
self::assertEquals(0.00, $sut->getRate());
self::assertNull($sut->getProject());
self::assertNull($sut->getUser());
self::assertEquals(3, $sut->getScore());
self::assertFalse($sut->isFixed());
}
public function testSetterAndGetter()
{
$sut = new ProjectRate();
self::assertInstanceOf(ProjectRate::class, $sut->setIsFixed(true));
self::assertTrue($sut->isFixed());
self::assertInstanceOf(ProjectRate::class, $sut->setRate(12.34));
self::assertEquals(12.34, $sut->getRate());
$user = new User();
$user->setAlias('foo');
$user->setUsername('bar');
self::assertInstanceOf(ProjectRate::class, $sut->setUser($user));
self::assertSame($user, $sut->getUser());
$entity = new Project();
$entity->setName('foo');
self::assertInstanceOf(ProjectRate::class, $sut->setProject($entity));
self::assertSame($entity, $sut->getProject());
}
}

View File

@@ -34,8 +34,6 @@ class ProjectTest extends TestCase
self::assertNull($sut->getComment()); self::assertNull($sut->getComment());
self::assertTrue($sut->getVisible()); self::assertTrue($sut->getVisible());
self::assertTrue($sut->isVisible()); self::assertTrue($sut->isVisible());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getHourlyRate());
self::assertNull($sut->getColor()); self::assertNull($sut->getColor());
self::assertEquals(0.0, $sut->getBudget()); self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget()); self::assertEquals(0, $sut->getTimeBudget());
@@ -85,12 +83,6 @@ class ProjectTest extends TestCase
self::assertInstanceOf(Project::class, $sut->setVisible(false)); self::assertInstanceOf(Project::class, $sut->setVisible(false));
self::assertFalse($sut->getVisible()); self::assertFalse($sut->getVisible());
self::assertInstanceOf(Project::class, $sut->setFixedRate(13.47));
self::assertEquals(13.47, $sut->getFixedRate());
self::assertInstanceOf(Project::class, $sut->setHourlyRate(99));
self::assertEquals(99, $sut->getHourlyRate());
self::assertInstanceOf(Project::class, $sut->setBudget(12345.67)); self::assertInstanceOf(Project::class, $sut->setBudget(12345.67));
self::assertEquals(12345.67, $sut->getBudget()); self::assertEquals(12345.67, $sut->getBudget());

View File

@@ -40,12 +40,6 @@ class InvoiceModelActivityHydratorTest extends TestCase
'activity.id', 'activity.id',
'activity.name', 'activity.name',
'activity.comment', 'activity.comment',
'activity.fixed_rate',
'activity.fixed_rate_nc',
'activity.fixed_rate_plain',
'activity.hourly_rate',
'activity.hourly_rate_nc',
'activity.hourly_rate_plain',
'activity.meta.foo-activity', 'activity.meta.foo-activity',
]; ];

View File

@@ -47,12 +47,6 @@ class InvoiceModelCustomerHydratorTest extends TestCase
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',
'customer.comment', 'customer.comment',
'customer.fixed_rate',
'customer.fixed_rate_nc',
'customer.fixed_rate_plain',
'customer.hourly_rate',
'customer.hourly_rate_nc',
'customer.hourly_rate_plain',
'customer.meta.foo-customer', 'customer.meta.foo-customer',
]; ];

View File

@@ -42,12 +42,6 @@ class InvoiceModelProjectHydratorTest extends TestCase
'project.comment', 'project.comment',
'project.order_date', 'project.order_date',
'project.order_number', 'project.order_number',
'project.fixed_rate',
'project.fixed_rate_nc',
'project.fixed_rate_plain',
'project.hourly_rate',
'project.hourly_rate_nc',
'project.hourly_rate_plain',
'project.meta.foo-project', 'project.meta.foo-project',
'project.start_date', 'project.start_date',
'project.end_date', 'project.end_date',

View File

@@ -119,22 +119,10 @@ class DebugRendererTest extends TestCase
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',
'customer.comment', 'customer.comment',
'customer.fixed_rate',
'customer.fixed_rate_nc',
'customer.fixed_rate_plain',
'customer.hourly_rate',
'customer.hourly_rate_nc',
'customer.hourly_rate_plain',
'customer.meta.foo-customer', 'customer.meta.foo-customer',
'activity.id', 'activity.id',
'activity.name', 'activity.name',
'activity.comment', 'activity.comment',
'activity.fixed_rate',
'activity.fixed_rate_nc',
'activity.fixed_rate_plain',
'activity.hourly_rate',
'activity.hourly_rate_nc',
'activity.hourly_rate_plain',
'activity.meta.foo-activity', 'activity.meta.foo-activity',
'user.alias', 'user.alias',
'user.email', 'user.email',
@@ -152,12 +140,6 @@ class DebugRendererTest extends TestCase
'project.comment', 'project.comment',
'project.order_date', 'project.order_date',
'project.order_number', 'project.order_number',
'project.fixed_rate',
'project.fixed_rate_nc',
'project.fixed_rate_plain',
'project.hourly_rate',
'project.hourly_rate_nc',
'project.hourly_rate_plain',
'project.meta.foo-project', 'project.meta.foo-project',
'project.start_date', 'project.start_date',
'project.end_date', 'project.end_date',

View File

@@ -10,11 +10,15 @@
namespace App\Tests\Timesheet\Calculator; namespace App\Tests\Timesheet\Calculator;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\CustomerRate;
use App\Entity\Project; use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference; use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\Calculator\RateCalculator; use App\Timesheet\Calculator\RateCalculator;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -23,6 +27,16 @@ use PHPUnit\Framework\TestCase;
*/ */
class RateCalculatorTest extends TestCase class RateCalculatorTest extends TestCase
{ {
protected function getRateRepositoryMock(array $rates = [])
{
$mock = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->getMock();
if (!empty($rates)) {
$mock->expects($this->any())->method('findMatchingRates')->willReturn($rates);
}
return $mock;
}
public function testCalculateWithTimesheetHourlyRate() public function testCalculateWithTimesheetHourlyRate()
{ {
$record = new Timesheet(); $record = new Timesheet();
@@ -31,7 +45,7 @@ class RateCalculatorTest extends TestCase
$record->setHourlyRate(100); $record->setHourlyRate(100);
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$sut = new RateCalculator([]); $sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals(50, $record->getRate()); $this->assertEquals(50, $record->getRate());
} }
@@ -46,7 +60,7 @@ class RateCalculatorTest extends TestCase
$record->setHourlyRate(99); $record->setHourlyRate(99);
$record->setActivity(new Activity()); $record->setActivity(new Activity());
$sut = new RateCalculator([]); $sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals(10, $record->getRate()); $this->assertEquals(10, $record->getRate());
} }
@@ -93,24 +107,12 @@ class RateCalculatorTest extends TestCase
$customerFixed $customerFixed
) { ) {
$customer = new Customer(); $customer = new Customer();
$customer
->setHourlyRate($customerHourly)
->setFixedRate($customerFixed)
;
$project = new Project(); $project = new Project();
$project $project->setCustomer($customer);
->setHourlyRate($projectHourly)
->setFixedRate($projectFixed)
->setCustomer($customer)
;
$activity = new Activity(); $activity = new Activity();
$activity $activity->setProject($project);
->setHourlyRate($activityHourly)
->setFixedRate($activityFixed)
->setProject($project)
;
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet $timesheet
@@ -123,7 +125,27 @@ class RateCalculatorTest extends TestCase
->setUser($this->getTestUser($userRate)) ->setUser($this->getTestUser($userRate))
; ;
$sut = new RateCalculator([]); $rates = [];
if (null !== $customerFixed) {
$rates[] = (new CustomerRate())->setRate($customerFixed)->setIsFixed(true);
} elseif (null !== $customerHourly) {
$rates[] = (new CustomerRate())->setRate($customerHourly);
}
if (null !== $projectFixed) {
$rates[] = (new ProjectRate())->setRate($projectFixed)->setIsFixed(true);
} elseif (null !== $projectHourly) {
$rates[] = (new ProjectRate())->setRate($projectHourly);
}
if (null !== $activityFixed) {
$rates[] = (new ActivityRate())->setRate($activityFixed)->setIsFixed(true);
} elseif (null !== $activityHourly) {
$rates[] = (new ActivityRate())->setRate($activityHourly);
}
$sut = new RateCalculator([], $this->getRateRepositoryMock($rates));
$sut->calculate($timesheet); $sut->calculate($timesheet);
$this->assertEquals($exptectedRate, $timesheet->getRate()); $this->assertEquals($exptectedRate, $timesheet->getRate());
} }
@@ -151,7 +173,7 @@ class RateCalculatorTest extends TestCase
$this->assertEquals(0, $record->getRate()); $this->assertEquals(0, $record->getRate());
$sut = new RateCalculator([]); $sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals(0, $record->getRate()); $this->assertEquals(0, $record->getRate());
} }
@@ -177,7 +199,7 @@ class RateCalculatorTest extends TestCase
$record->setEnd($end); $record->setEnd($end);
$sut = new RateCalculator($rules); $sut = new RateCalculator($rules, $this->getRateRepositoryMock());
$sut->calculate($record); $sut->calculate($record);
$this->assertEquals($expectedRate, $record->getRate()); $this->assertEquals($expectedRate, $record->getRate());

View File

@@ -66,7 +66,15 @@
</trans-unit> </trans-unit>
<trans-unit id="attachments"> <trans-unit id="attachments">
<source>attachments</source> <source>attachments</source>
<target>Files</target> <target>Dateien</target>
</trans-unit>
<trans-unit id="rates.empty">
<source>rates.empty</source>
<target>Es wurden noch keine Gebühren hinterlegt.</target>
</trans-unit>
<trans-unit id="rates.title">
<source>rates.title</source>
<target>Gebühren</target>
</trans-unit> </trans-unit>
<!-- <!--

View File

@@ -66,7 +66,15 @@
</trans-unit> </trans-unit>
<trans-unit id="attachments"> <trans-unit id="attachments">
<source>attachments</source> <source>attachments</source>
<target>Dateien</target> <target>Files</target>
</trans-unit>
<trans-unit id="rates.empty">
<source>rates.empty</source>
<target>No fees have been configured yet.</target>
</trans-unit>
<trans-unit id="rates.title">
<source>rates.title</source>
<target>Fees</target>
</trans-unit> </trans-unit>
<!-- <!--

Binary file not shown.