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:
pull_request: null
push:
@@ -11,7 +11,7 @@ jobs:
matrix:
php: ['7.4']
name: PHP ${{ matrix.php }}
name: Coverage - PHP ${{ matrix.php }}
steps:
- uses: actions/checkout@v2
- uses: shivammathur/setup-php@v1

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
# 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)
[![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)
[![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
- PHP 7.2 or higher
- Database (MySQL/MariaDB with timezone data, SQLite for development)
- PHP 7.2.9 or higher
- Database (MySQL/MariaDB, SQLite for development)
- Webserver (nginx, Apache)
- A modern browser
- [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:
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.
## Installation

View File

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

View File

@@ -1,6 +1,6 @@
App\Entity\Activity:
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:
id:
include: true
@@ -20,12 +20,6 @@ App\Entity\Activity:
timeBudget:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Activity]
hourlyRate:
include: true
groups: [Activity]
project:
include: false
exclude: true

View File

@@ -1,6 +1,6 @@
App\Entity\Customer:
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:
id:
include: true
@@ -53,12 +53,6 @@ App\Entity\Customer:
timezone:
include: true
groups: [Entity]
fixedRate:
include: true
groups: [Customer]
hourlyRate:
include: true
groups: [Customer]
color:
include: true
metaFields:

View File

@@ -1,6 +1,6 @@
App\Entity\Project:
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:
id:
include: true
@@ -29,12 +29,6 @@ App\Entity\Project:
end:
include: true
groups: [Project]
fixedRate:
include: true
groups: [Project]
hourlyRate:
include: true
groups: [Project]
customer:
groups: [Subresource]
color:

View File

@@ -211,3 +211,18 @@ services:
App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository
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/0.f7fae2b9.js",
"build/1.c24a9c6f.js",
"build/app.480acc52.js"
"build/app.9c136e43.js"
],
"css": [
"build/app.2bde4971.css"
@@ -35,7 +35,7 @@
"build/runtime.664a9501.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.f7fae2b9.js": "sha384-DR5A41RIECdWBd6xODR0b1hvGqcEYUyn9vNPreqK9VOCQWTF6bgxB4RVmqlOKilT",
"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/2.a47ad919.js": "sha384-1h2P/tsl+bh8qgJd7S9irQHKuns7UATVxeFGLOCH85GPFXfAdRzgzx3nk5MrxzrV",
"build/chart.c9943f57.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",

View File

@@ -3,7 +3,7 @@
"build/1.c24a9c6f.js": "build/1.c24a9c6f.js",
"build/2.a47ad919.js": "build/2.a47ad919.js",
"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.js": "build/calendar.75c80d8f.js",
"build/chart.js": "build/chart.c9943f57.js",

View File

@@ -11,17 +11,19 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
use App\Event\ActivityMetaDefinitionEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Form\ActivityEditForm;
use App\Form\ActivityRateForm;
use App\Form\Toolbar\ActivityToolbarForm;
use App\Form\Type\ActivityType;
use App\Repository\ActivityRateRepository;
use App\Repository\ActivityRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -37,7 +39,7 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/admin/activity")
* @Security("is_granted('view_activity')")
*/
class ActivityController extends AbstractController
final class ActivityController extends AbstractController
{
/**
* @var ActivityRepository
@@ -50,7 +52,7 @@ class ActivityController extends AbstractController
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
private $dispatcher;
public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
@@ -59,11 +61,6 @@ class ActivityController extends AbstractController
$this->dispatcher = $dispatcher;
}
protected function getRepository(): ActivityRepository
{
return $this->repository;
}
/**
* @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"})
@@ -88,7 +85,7 @@ class ActivityController extends AbstractController
}
/* @var $entries Pagerfanta */
$entries = $this->getRepository()->getPagerfantaForQuery($query);
$entries = $this->repository->getPagerfantaForQuery($query);
return $this->render('activity/index.html.twig', [
'entries' => $entries,
@@ -110,6 +107,85 @@ class ActivityController extends AbstractController
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/{project}", name="admin_activity_create_with_project", methods={"GET", "POST"})
@@ -129,25 +205,6 @@ class ActivityController extends AbstractController
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"})
* @Security("is_granted('edit', activity)")
@@ -171,7 +228,7 @@ class ActivityController extends AbstractController
*/
public function deleteAction(Activity $activity, Request $request)
{
$stats = $this->getRepository()->getActivityStatistics($activity);
$stats = $this->repository->getActivityStatistics($activity);
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
@@ -199,9 +256,9 @@ class ActivityController extends AbstractController
if ($deleteForm->isSubmitted() && $deleteForm->isValid()) {
try {
$this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->repository->deleteActivity($activity, $deleteForm->get('activity')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
@@ -233,7 +290,7 @@ class ActivityController extends AbstractController
if ($editForm->isSubmitted() && $editForm->isValid()) {
try {
$this->getRepository()->saveActivity($activity);
$this->repository->saveActivity($activity);
$this->flashSuccess('action.update.success');
if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) {
@@ -245,7 +302,7 @@ class ActivityController extends AbstractController
} else {
return $this->redirectToRoute('admin_activity');
}
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
}
}

View File

@@ -12,22 +12,25 @@ namespace App\Controller;
use App\Configuration\FormConfiguration;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Rate;
use App\Entity\Team;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Form\CustomerCommentForm;
use App\Form\CustomerEditForm;
use App\Form\CustomerRateForm;
use App\Form\CustomerTeamPermissionForm;
use App\Form\Toolbar\CustomerToolbarForm;
use App\Form\Type\CustomerType;
use App\Repository\CustomerRateRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\TeamRepository;
use Doctrine\ORM\ORMException;
use Pagerfanta\Pagerfanta;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -257,7 +260,7 @@ final class CustomerController extends AbstractController
* @Route(path="/{id}/details", name="customer_details", methods={"GET", "POST"})
* @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);
$this->dispatcher->dispatch($event);
@@ -270,10 +273,14 @@ final class CustomerController extends AbstractController
$comments = null;
$teams = 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()]);
}
$rates = $rateRepository->getRatesForCustomer($customer);
}
if (null !== $customer->getTimezone()) {
$timezone = new \DateTimeZone($customer->getTimezone());
@@ -304,6 +311,59 @@ final class CustomerController extends AbstractController
'team' => $defaultTeam,
'teams' => $teams,
'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 {
$this->repository->deleteCustomer($customer, $deleteForm->get('customer')->getData());
$this->flashSuccess('action.delete.success');
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
@@ -383,7 +443,7 @@ final class CustomerController extends AbstractController
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$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\Project;
use App\Entity\ProjectComment;
use App\Entity\ProjectRate;
use App\Entity\Rate;
use App\Entity\Team;
use App\Event\ProjectMetaDefinitionEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Form\ProjectCommentForm;
use App\Form\ProjectEditForm;
use App\Form\ProjectRateForm;
use App\Form\ProjectTeamPermissionForm;
use App\Form\Toolbar\ProjectToolbarForm;
use App\Form\Type\ProjectType;
use App\Repository\ActivityRepository;
use App\Repository\ProjectRateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ProjectFormTypeQuery;
@@ -261,7 +265,7 @@ final class ProjectController extends AbstractController
* @Route(path="/{id}/details", name="project_details", methods={"GET", "POST"})
* @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);
$this->dispatcher->dispatch($event);
@@ -272,10 +276,14 @@ final class ProjectController extends AbstractController
$attachments = [];
$comments = 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()]);
}
$rates = $rateRepository->getRatesForProject($project);
}
if ($this->isGranted('budget', $project)) {
$stats = $this->repository->getProjectStatistics($project);
@@ -301,6 +309,59 @@ final class ProjectController extends AbstractController
'stats' => $stats,
'team' => $defaultTeam,
'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;
// keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait;
use BudgetTrait;
@@ -138,6 +137,11 @@ class Activity implements EntityWithMetaFields
return $this;
}
public function isGlobal(): bool
{
return $this->project === null;
}
public function isVisible(): bool
{
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;
// keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait;
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;
// keep the trait include exactly here, for placing the column at the correct position
use RatesTrait;
use ColorTrait;
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;
// 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
@@ -452,6 +465,30 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
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
* @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')) {
$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);
}
if ($auth->isGranted('view_project') || $auth->isGranted('view_teamlead_project') || $auth->isGranted('view_team_project')) {
$projects = new MenuItemModel('project_admin', 'menu.admin_project', 'admin_project', [], $this->getIcon('project'));
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'admin_project_budget', 'admin_project_edit', 'admin_project_delete']);
$projects->setChildRoutes(['admin_project_permissions', 'admin_project_create', 'project_details', 'admin_project_edit', 'admin_project_delete']);
$menu->addChild($projects);
}
if ($auth->isGranted('view_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);
}

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\DurationType;
use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -26,12 +24,6 @@ trait EntityFormTrait
$currency = $options['currency'];
$builder
->add('color', ColorPickerType::class)
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
])
;
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.name' => $activity->getName(),
'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) {

View File

@@ -36,12 +36,6 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'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) {

View File

@@ -33,12 +33,6 @@ class InvoiceModelProjectHydrator implements InvoiceModelHydrator
'project.start_date' => null !== $project->getStart() ? $formatter->getFormattedDateTime($project->getStart()) : '',
'project.end_date' => null !== $project->getEnd() ? $formatter->getFormattedDateTime($project->getEnd()) : '',
'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_nc' => $formatter->getFormattedMoney($project->getBudget(), null),
'project.budget_money_plain' => $project->getBudget(),

View File

@@ -14,6 +14,11 @@ namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds language and decimal_duration column to invoice template table
*
* @version 1.8
*/
final class Version20200204124425 extends AbstractMigration
{
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;
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\User;
use App\Model\Statistic\Day;
@@ -834,4 +838,73 @@ class TimesheetRepository extends EntityRepository
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;
use App\Entity\Rate;
use App\Entity\RateInterface;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\CalculatorInterface;
use App\Timesheet\Util;
@@ -22,15 +25,16 @@ class RateCalculator implements CalculatorInterface
/**
* @var array
*/
protected $rates;
private $rates;
/**
* RateCalculator constructor.
* @param array $rates
* @var TimesheetRepository
*/
public function __construct(array $rates)
private $repository;
public function __construct(array $rates, TimesheetRepository $repository)
{
$this->rates = $rates;
$this->repository = $repository;
}
/**
@@ -44,7 +48,21 @@ class RateCalculator implements CalculatorInterface
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) {
$record->setRate($fixedRate);
$record->setFixedRate($fixedRate);
@@ -52,74 +70,40 @@ class RateCalculator implements CalculatorInterface
return;
}
$hourlyRate = $this->findHourlyRate($record);
if (null === $hourlyRate) {
$hourlyRate = (float) $record->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0.00);
}
$factor = $this->getRateFactor($record);
$hourlyRate = (float) ($hourlyRate * $factor);
$rate = 0;
$totalRate = 0;
if (null !== $record->getDuration()) {
$rate = Util::calculateRate($hourlyRate, $record->getDuration());
$totalRate = Util::calculateRate($hourlyRate, $record->getDuration());
}
$record->setHourlyRate($hourlyRate);
$record->setRate($rate);
$record->setRate($totalRate);
}
/**
* @param Timesheet $record
* @return float
*/
protected function findHourlyRate(Timesheet $record)
private function getBestFittingRate(Timesheet $timesheet): ?RateInterface
{
if (null !== $record->getHourlyRate()) {
return $record->getHourlyRate();
$rates = $this->repository->findMatchingRates($timesheet);
/** @var RateInterface[] $sorted */
$sorted = [];
foreach ($rates as $rate) {
$score = $rate->getScore();
if (null !== $rate->getUser() && $timesheet->getUser() === $rate->getUser()) {
++$score;
}
$activity = $record->getActivity();
if (null !== $activity->getHourlyRate()) {
return $activity->getHourlyRate();
$sorted[$score] = $rate;
}
$project = $record->getProject();
if (null !== $project) {
if (null !== $project->getHourlyRate()) {
return $project->getHourlyRate();
}
if (!empty($sorted)) {
ksort($sorted);
$customer = $project->getCustomer();
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 end($sorted);
}
return null;

View File

@@ -18,6 +18,9 @@
{% set actions = {} %}
{% 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) %}
{% set class = '' %}
{% if view != 'edit' %}
@@ -25,9 +28,6 @@
{% endif %}
{% set actions = actions|merge({'edit': {'url': path('admin_activity_edit', {'id': activity.id}), 'class': class}}) %}
{% endif %}
{% if is_granted('budget', activity) %}
{% set actions = actions|merge({'report': {'url': path('admin_activity_budget', {'id': activity.id})}}) %}
{% endif %}
{% if actions|length > 0 %}
{% set actions = actions|merge({'divider': null}) %}
{% endif %}

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.customer) }}
{{ 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 %}
<div class="row">
<div class="col-md-6">
@@ -45,13 +35,9 @@
</div>
</div>
{% endif %}
</fieldset>
{{ form_row(form.visible) }}
{% if form.metaFields is defined and form.metaFields is not empty %}
<fieldset>
{#<legend>Custom fields</legend>#}
{{ form_row(form.metaFields) }}
</fieldset>
{% endif %}
{{ form_widget(form) }}
{% 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'}) }}
{% 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 class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{# 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>
{% 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 %}
<tr>
<th>{{ metaField.label }}</th>
@@ -146,6 +124,67 @@
{{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }}
{% 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 %}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', customer) %}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
{% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as actions %}
{% 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 %}
{% form_theme form form_theme %}
{% endif %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'boxtype': 'primary'} %}
{% block box_before %}
<div class="box box-primary">
{% block form_before %}{% endblock %}
{{ form_start(form) }}
{% endblock %}
{% block box_title %}
<div class="box-header with-border">
<h3 class="box-title">
{{ title }}
{% 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>
{% endif %}
{% endblock %}
{% block box_body %}
</h3>
</div>
<div class="box-body">
{% block form_body %}
{{ form_widget(form) }}
{% endblock %}
{% endblock %}
{% block box_footer %}
</div>
<div class="box-footer">
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
@@ -25,9 +25,7 @@
{% if reset|default(true) %}
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
{% endif %}
{% endblock %}
{% block box_after %}
</div>
{{ form_end(form) }}
{% block form_after %}{% endblock %}
{% endblock %}
{% endembed %}
</div>

View File

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

View File

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

View File

@@ -82,28 +82,6 @@
</td>
</tr>
{% 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 %}
<tr>
<th>{{ metaField.label }}</th>
@@ -120,6 +98,67 @@
{{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }}
{% 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%}
{% set options = {'teams': teams, 'team': team} %}
{% if is_granted('permissions', project) %}

View File

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

View File

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

View File

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

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)
{
$expectedKeys = [
'id', 'name', 'visible', 'project', 'hourlyRate', 'fixedRate', 'color', 'metaFields', 'parentTitle'
'id', 'name', 'visible', 'project', 'color', 'metaFields', 'parentTitle'
];
if ($full) {

View File

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

View File

@@ -152,7 +152,7 @@ class ProjectControllerTest extends APIControllerBaseTest
$data = [
'name' => 'foo',
'customer' => 1,
'visible' => true
'visible' => true,
];
$this->request($client, '/api/projects', 'POST', [], json_encode($data));
$response = $client->getResponse();
@@ -283,7 +283,7 @@ class ProjectControllerTest extends APIControllerBaseTest
protected function assertStructure(array $result, $full = true)
{
$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) {

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Controller;
use App\Entity\Activity;
use App\Entity\ActivityMeta;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\ActivityFixtures;
@@ -67,7 +68,7 @@ class ActivityControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_activity_admin', 5);
}
public function testBudgetAction()
public function testDetailsAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
@@ -79,9 +80,70 @@ class ActivityControllerTest extends ControllerBaseTest
$fixture->setUser($this->getUserByRole($em, User::ROLE_ADMIN));
$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);
$this->assertAccessIsGranted($client, '/admin/activity/1/budget');
$this->assertAccessIsGranted($client, '/admin/activity/1/details');
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()

View File

@@ -87,6 +87,56 @@ class CustomerControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
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()

View File

@@ -106,6 +106,56 @@ class ProjectControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box a.btn-box-tool');
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()

View File

@@ -33,7 +33,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
$result = $client->getCrawler()->filter('section.content div.box.box-primary');
$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));
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\ActivityMeta;
use App\Entity\Project;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
@@ -27,8 +28,7 @@ class ActivityTest extends TestCase
$this->assertNull($sut->getName());
$this->assertNull($sut->getComment());
$this->assertTrue($sut->getVisible());
$this->assertNull($sut->getFixedRate());
$this->assertNull($sut->getHourlyRate());
$this->assertTrue($sut->isGlobal());
$this->assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget());
@@ -53,17 +53,15 @@ class ActivityTest extends TestCase
$this->assertInstanceOf(Activity::class, $sut->setColor('#fffccc'));
$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->assertEquals(12345.67, $sut->getBudget());
$this->assertInstanceOf(Activity::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget());
$this->assertTrue($sut->isGlobal());
$this->assertInstanceOf(Activity::class, $sut->setProject(new Project()));
$this->assertFalse($sut->isGlobal());
}
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->getTimezone());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getHourlyRate());
self::assertNull($sut->getColor());
self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget());
@@ -92,12 +90,6 @@ class CustomerTest extends TestCase
self::assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
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::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::assertTrue($sut->getVisible());
self::assertTrue($sut->isVisible());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getHourlyRate());
self::assertNull($sut->getColor());
self::assertEquals(0.0, $sut->getBudget());
self::assertEquals(0, $sut->getTimeBudget());
@@ -85,12 +83,6 @@ class ProjectTest extends TestCase
self::assertInstanceOf(Project::class, $sut->setVisible(false));
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::assertEquals(12345.67, $sut->getBudget());

View File

@@ -40,12 +40,6 @@ class InvoiceModelActivityHydratorTest extends TestCase
'activity.id',
'activity.name',
'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',
];

View File

@@ -47,12 +47,6 @@ class InvoiceModelCustomerHydratorTest extends TestCase
'customer.number',
'customer.homepage',
'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',
];

View File

@@ -42,12 +42,6 @@ class InvoiceModelProjectHydratorTest extends TestCase
'project.comment',
'project.order_date',
'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.start_date',
'project.end_date',

View File

@@ -119,22 +119,10 @@ class DebugRendererTest extends TestCase
'customer.number',
'customer.homepage',
'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',
'activity.id',
'activity.name',
'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',
'user.alias',
'user.email',
@@ -152,12 +140,6 @@ class DebugRendererTest extends TestCase
'project.comment',
'project.order_date',
'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.start_date',
'project.end_date',

View File

@@ -10,11 +10,15 @@
namespace App\Tests\Timesheet\Calculator;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\Customer;
use App\Entity\CustomerRate;
use App\Entity\Project;
use App\Entity\ProjectRate;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\TimesheetRepository;
use App\Timesheet\Calculator\RateCalculator;
use PHPUnit\Framework\TestCase;
@@ -23,6 +27,16 @@ use PHPUnit\Framework\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()
{
$record = new Timesheet();
@@ -31,7 +45,7 @@ class RateCalculatorTest extends TestCase
$record->setHourlyRate(100);
$record->setActivity(new Activity());
$sut = new RateCalculator([]);
$sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record);
$this->assertEquals(50, $record->getRate());
}
@@ -46,7 +60,7 @@ class RateCalculatorTest extends TestCase
$record->setHourlyRate(99);
$record->setActivity(new Activity());
$sut = new RateCalculator([]);
$sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record);
$this->assertEquals(10, $record->getRate());
}
@@ -93,24 +107,12 @@ class RateCalculatorTest extends TestCase
$customerFixed
) {
$customer = new Customer();
$customer
->setHourlyRate($customerHourly)
->setFixedRate($customerFixed)
;
$project = new Project();
$project
->setHourlyRate($projectHourly)
->setFixedRate($projectFixed)
->setCustomer($customer)
;
$project->setCustomer($customer);
$activity = new Activity();
$activity
->setHourlyRate($activityHourly)
->setFixedRate($activityFixed)
->setProject($project)
;
$activity->setProject($project);
$timesheet = new Timesheet();
$timesheet
@@ -123,7 +125,27 @@ class RateCalculatorTest extends TestCase
->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);
$this->assertEquals($exptectedRate, $timesheet->getRate());
}
@@ -151,7 +173,7 @@ class RateCalculatorTest extends TestCase
$this->assertEquals(0, $record->getRate());
$sut = new RateCalculator([]);
$sut = new RateCalculator([], $this->getRateRepositoryMock());
$sut->calculate($record);
$this->assertEquals(0, $record->getRate());
}
@@ -177,7 +199,7 @@ class RateCalculatorTest extends TestCase
$record->setEnd($end);
$sut = new RateCalculator($rules);
$sut = new RateCalculator($rules, $this->getRateRepositoryMock());
$sut->calculate($record);
$this->assertEquals($expectedRate, $record->getRate());

View File

@@ -66,7 +66,15 @@
</trans-unit>
<trans-unit id="attachments">
<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>
<!--

View File

@@ -66,7 +66,15 @@
</trans-unit>
<trans-unit id="attachments">
<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>
<!--

Binary file not shown.