Release 2.0.20 (#4028)
* max duration per entry increased from 8 to 10 hours * fixes #3981 - clickable area in dropdown too small * fixes #4008 - duplicate activities in project-details report * headers and summary styling in project-details report * show billable stats in project-details report * added new invoice variable for entry.duration_format
This commit is contained in:
@@ -73,6 +73,7 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
|
||||
let options = {
|
||||
lockOptgroupOrder: true,
|
||||
allowEmptyOption: !isRequired,
|
||||
hidePlaceholder: false,
|
||||
plugins: plugins,
|
||||
// if there are more than X entries, the other ones are hidden and can only be found
|
||||
// by typing some characters to trigger the internal option search
|
||||
|
||||
@@ -79,15 +79,6 @@ table.dataTable thead > tr > th.hw-min {
|
||||
max-height: 340px;
|
||||
}
|
||||
|
||||
.card {
|
||||
.card-tools {
|
||||
/* a button that is directly connected with a pagination element inside the box tools (eg. project list on customer detail page) */
|
||||
.btn-pager {
|
||||
float: left;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
#customer_details_box,
|
||||
#project_details_box,
|
||||
#activity_details_box {
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"friendsofsymfony/rest-bundle": "^3.0",
|
||||
"gedmo/doctrine-extensions": "^3.6",
|
||||
"jms/serializer-bundle": "^5.0",
|
||||
"kevinpapst/tabler-bundle": "^0.16",
|
||||
"kevinpapst/tabler-bundle": "^0.17",
|
||||
"league/csv": "^9.4",
|
||||
"mpdf/mpdf": "^8.0",
|
||||
"nelmio/api-doc-bundle": "^4.0",
|
||||
|
||||
14
composer.lock
generated
14
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "26d6641fd4c8f977b0a50cab05e1df87",
|
||||
"content-hash": "63b69c5c8020a78b308210425547d46c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -2416,16 +2416,16 @@
|
||||
},
|
||||
{
|
||||
"name": "kevinpapst/tabler-bundle",
|
||||
"version": "0.16",
|
||||
"version": "0.17",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/kevinpapst/TablerBundle.git",
|
||||
"reference": "c5986daa3595fc5163f7d0cb15f69dc5f696b987"
|
||||
"reference": "0061d82a28059cf876cf819f1780607aac131d32"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/c5986daa3595fc5163f7d0cb15f69dc5f696b987",
|
||||
"reference": "c5986daa3595fc5163f7d0cb15f69dc5f696b987",
|
||||
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/0061d82a28059cf876cf819f1780607aac131d32",
|
||||
"reference": "0061d82a28059cf876cf819f1780607aac131d32",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2474,7 +2474,7 @@
|
||||
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
|
||||
"support": {
|
||||
"issues": "https://github.com/kevinpapst/TablerBundle/issues",
|
||||
"source": "https://github.com/kevinpapst/TablerBundle/tree/0.16"
|
||||
"source": "https://github.com/kevinpapst/TablerBundle/tree/0.17"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2486,7 +2486,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2023-05-15T12:01:30+00:00"
|
||||
"time": "2023-05-15T18:25:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laminas/laminas-escaper",
|
||||
|
||||
43
phpstan.neon
43
phpstan.neon
@@ -4836,11 +4836,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Plugin/PluginMetadata.php
|
||||
|
||||
-
|
||||
message: "#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot access offset 'amount' on mixed\\.$#"
|
||||
count: 1
|
||||
@@ -4881,16 +4876,6 @@ parameters:
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Activity\\|null\\.$#"
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getId\\(\\) on mixed\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getMonth\\(\\) on App\\\\Model\\\\Statistic\\\\Year\\|null\\.$#"
|
||||
count: 1
|
||||
@@ -4911,24 +4896,14 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$activity of method App\\\\Model\\\\ActivityStatistic\\:\\:setActivity\\(\\) expects App\\\\Entity\\\\Activity, App\\\\Entity\\\\Activity\\|null given\\.$#"
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$billableDuration of method App\\\\Model\\\\Statistic\\\\Month\\:\\:setBillableDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$billableDuration of method App\\\\Model\\\\Statistic\\\\Year\\:\\:setBillableDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounter\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 3
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
@@ -4948,7 +4923,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$duration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 3
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
@@ -4971,11 +4946,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$key of function array_key_exists expects int\\|string, int\\|null given\\.$#"
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$project of class App\\\\Reporting\\\\ProjectDetails\\\\ProjectDetailsModel constructor expects App\\\\Entity\\\\Project, App\\\\Entity\\\\Project\\|null given\\.$#"
|
||||
count: 1
|
||||
@@ -4993,7 +4963,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 3
|
||||
count: 1
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
@@ -5013,12 +4983,7 @@ parameters:
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$totalDuration of method App\\\\Model\\\\Statistic\\\\Timesheet\\:\\:setTotalDuration\\(\\) expects int, float\\|int given\\.$#"
|
||||
count: 3
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$user of class App\\\\Model\\\\UserStatistic constructor expects App\\\\Entity\\\\User, mixed given\\.$#"
|
||||
count: 1
|
||||
count: 2
|
||||
path: src/Project/ProjectStatisticService.php
|
||||
|
||||
-
|
||||
|
||||
2
public/build/app.45d541e0.js
Normal file
2
public/build/app.45d541e0.js
Normal file
File diff suppressed because one or more lines are too long
1
public/build/app.66162a27.css
Normal file
1
public/build/app.66162a27.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,10 +3,10 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"/build/runtime.f0079159.js",
|
||||
"/build/app.bed7a1bf.js"
|
||||
"/build/app.45d541e0.js"
|
||||
],
|
||||
"css": [
|
||||
"/build/app.80fb16e8.css"
|
||||
"/build/app.66162a27.css"
|
||||
]
|
||||
},
|
||||
"export-pdf": {
|
||||
@@ -63,8 +63,8 @@
|
||||
},
|
||||
"integrity": {
|
||||
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
|
||||
"/build/app.bed7a1bf.js": "sha384-OCg9guQNr23augSqlruX0IK2FWrKO1MWMx0sBRIzAkKgg561U4stZRHfxQbrwJeG",
|
||||
"/build/app.80fb16e8.css": "sha384-kDryvDuNVyJAdIyQEuF5NpT1duQ4VmScmUNtha3B0ssQyzLuQdWBz/G3m70wao12",
|
||||
"/build/app.45d541e0.js": "sha384-KhCwP7jLZJ8+g02Lr+oITVZ8woE+EU01t/VQNuDyHmpVPCVggaaoI9EU9lJeB62l",
|
||||
"/build/app.66162a27.css": "sha384-TeR8dC4yDJMVshAZ+sa3WotB4RqTHOiTtHROHg7jXEE29WCabeBlXB6MKiPdnsu3",
|
||||
"/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u",
|
||||
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
|
||||
"/build/invoice.2604495e.js": "sha384-D6JvhGSqlx7z72b/qD3nF3QDXPy+XsCSRGtWfs1icjDKOcd2UzuXwuSa/E1Fg2TJ",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "/build/app.80fb16e8.css",
|
||||
"build/app.js": "/build/app.bed7a1bf.js",
|
||||
"build/app.css": "/build/app.66162a27.css",
|
||||
"build/app.js": "/build/app.45d541e0.js",
|
||||
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
|
||||
"build/export-pdf.js": "/build/export-pdf.d367a32e.js",
|
||||
"build/invoice.css": "/build/invoice.5bea118e.css",
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.0.19';
|
||||
public const VERSION = '2.0.20';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 20019;
|
||||
public const VERSION_ID = 20020;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -305,7 +305,7 @@ final class Configuration implements ConfigurationInterface
|
||||
->defaultValue(0)
|
||||
->end()
|
||||
->integerNode('long_running_duration')
|
||||
->defaultValue(480)
|
||||
->defaultValue(600)
|
||||
->end()
|
||||
->booleanNode('require_activity')
|
||||
->defaultTrue()
|
||||
|
||||
@@ -72,6 +72,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
|
||||
'entry.total_plain' => $rate,
|
||||
'entry.currency' => $currency,
|
||||
'entry.duration' => $item->getDuration(),
|
||||
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
|
||||
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
|
||||
'entry.duration_minutes' => (int) ($item->getDuration() / 60),
|
||||
'entry.begin' => $formatter->getFormattedDateTime($begin),
|
||||
|
||||
@@ -17,8 +17,7 @@ class Timesheet
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
* @see getDuration()
|
||||
*/
|
||||
public function getValue(): int
|
||||
{
|
||||
@@ -27,8 +26,7 @@ class Timesheet
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return int
|
||||
* @see getTotalDuration()
|
||||
*/
|
||||
public function getDuration(): int
|
||||
{
|
||||
@@ -47,8 +45,7 @@ class Timesheet
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return float
|
||||
* @see getTotalRate()
|
||||
*/
|
||||
public function getRate(): float
|
||||
{
|
||||
@@ -67,8 +64,7 @@ class Timesheet
|
||||
|
||||
/**
|
||||
* For unified access, used in frontend.
|
||||
*
|
||||
* @return float
|
||||
* @see getTotalInternalRate()
|
||||
*/
|
||||
public function getInternalRate(): float
|
||||
{
|
||||
|
||||
@@ -29,21 +29,26 @@ final class UserYear
|
||||
|
||||
public function getDuration(): int
|
||||
{
|
||||
$duration = 0;
|
||||
foreach ($this->year->getMonths() as $month) {
|
||||
$duration += $month->getDuration();
|
||||
}
|
||||
return $this->year->getDuration();
|
||||
}
|
||||
|
||||
return $duration;
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
return $this->year->getBillableDuration();
|
||||
}
|
||||
|
||||
public function getRate(): float
|
||||
{
|
||||
$rate = 0;
|
||||
foreach ($this->year->getMonths() as $month) {
|
||||
$rate += $month->getRate();
|
||||
}
|
||||
return $this->year->getRate();
|
||||
}
|
||||
|
||||
return $rate;
|
||||
public function getBillableRate(): float
|
||||
{
|
||||
return $this->year->getBillableRate();
|
||||
}
|
||||
|
||||
public function getInternalRate(): float
|
||||
{
|
||||
return $this->year->getInternalRate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,12 @@
|
||||
|
||||
namespace App\Model\Statistic;
|
||||
|
||||
final class Year extends Timesheet
|
||||
final class Year
|
||||
{
|
||||
/**
|
||||
* @var Month[]
|
||||
*/
|
||||
private array $months = [];
|
||||
private int $billableDuration = 0;
|
||||
private float $billableRate = 0.00;
|
||||
|
||||
public function __construct(private string $year)
|
||||
{
|
||||
@@ -51,23 +49,58 @@ final class Year extends Timesheet
|
||||
return array_values($this->months);
|
||||
}
|
||||
|
||||
public function getBillableDuration(): int
|
||||
public function getDuration(): int
|
||||
{
|
||||
return $this->billableDuration;
|
||||
$duration = 0;
|
||||
|
||||
foreach ($this->months as $month) {
|
||||
$duration += $month->getDuration();
|
||||
}
|
||||
|
||||
return $duration;
|
||||
}
|
||||
|
||||
public function setBillableDuration(int $billableDuration): void
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
$this->billableDuration = $billableDuration;
|
||||
$duration = 0;
|
||||
|
||||
foreach ($this->months as $month) {
|
||||
$duration += $month->getBillableDuration();
|
||||
}
|
||||
|
||||
return $duration;
|
||||
}
|
||||
|
||||
public function getRate(): float
|
||||
{
|
||||
$rate = 0.0;
|
||||
|
||||
foreach ($this->months as $month) {
|
||||
$rate += $month->getRate();
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function getBillableRate(): float
|
||||
{
|
||||
return $this->billableRate;
|
||||
$rate = 0.0;
|
||||
|
||||
foreach ($this->months as $month) {
|
||||
$rate += $month->getBillableRate();
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
|
||||
public function setBillableRate(float $billableRate): void
|
||||
public function getInternalRate(): float
|
||||
{
|
||||
$this->billableRate = $billableRate;
|
||||
$rate = 0.0;
|
||||
|
||||
foreach ($this->months as $month) {
|
||||
$rate += $month->getInternalRate();
|
||||
}
|
||||
|
||||
return $rate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use App\Reporting\ProjectDetails\ProjectDetailsQuery;
|
||||
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
|
||||
use App\Reporting\ProjectView\ProjectViewModel;
|
||||
use App\Reporting\ProjectView\ProjectViewQuery;
|
||||
use App\Repository\ActivityRepository;
|
||||
use App\Repository\Loader\ProjectLoader;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
@@ -35,7 +36,6 @@ use App\Repository\UserRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Query\Expr\Join;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,13 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
*/
|
||||
class ProjectStatisticService
|
||||
{
|
||||
public function __construct(private ProjectRepository $repository, private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher, private UserRepository $userRepository)
|
||||
public function __construct(
|
||||
private ProjectRepository $projectRepository,
|
||||
private ActivityRepository $activityRepository,
|
||||
private TimesheetRepository $timesheetRepository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private UserRepository $userRepository
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -74,7 +80,7 @@ class ProjectStatisticService
|
||||
$lastChange = clone $query->getLastChange();
|
||||
$now = new DateTime('now', $lastChange->getTimezone());
|
||||
|
||||
$qb2 = $this->repository->createQueryBuilder('t1');
|
||||
$qb2 = $this->projectRepository->createQueryBuilder('t1');
|
||||
$qb2
|
||||
->select('1')
|
||||
->from(Timesheet::class, 't')
|
||||
@@ -82,7 +88,7 @@ class ProjectStatisticService
|
||||
->andWhere($qb2->expr()->gte('t.begin', ':begin'))
|
||||
;
|
||||
|
||||
$qb = $this->repository->createQueryBuilder('p');
|
||||
$qb = $this->projectRepository->createQueryBuilder('p');
|
||||
$qb
|
||||
->select('p, c')
|
||||
->leftJoin('p.customer', 'c')
|
||||
@@ -99,13 +105,13 @@ class ProjectStatisticService
|
||||
->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
$this->repository->addPermissionCriteria($qb, $user);
|
||||
$this->projectRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Project[] $projects */
|
||||
$projects = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache customer objects instead of joining them
|
||||
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader->loadResults($projects);
|
||||
|
||||
return $projects;
|
||||
@@ -121,7 +127,7 @@ class ProjectStatisticService
|
||||
$begin = $dateRange->getBegin();
|
||||
$end = $dateRange->getEnd();
|
||||
|
||||
$qb = $this->repository->createQueryBuilder('p');
|
||||
$qb = $this->projectRepository->createQueryBuilder('p');
|
||||
$qb
|
||||
->select('p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
@@ -144,7 +150,7 @@ class ProjectStatisticService
|
||||
;
|
||||
|
||||
if (!$query->isIncludeNoWork()) {
|
||||
$qb2 = $this->repository->createQueryBuilder('t1');
|
||||
$qb2 = $this->projectRepository->createQueryBuilder('t1');
|
||||
$qb2
|
||||
->select('1')
|
||||
->from(Timesheet::class, 't')
|
||||
@@ -183,13 +189,13 @@ class ProjectStatisticService
|
||||
->setParameter('customer', $query->getCustomer());
|
||||
}
|
||||
|
||||
$this->repository->addPermissionCriteria($qb, $user);
|
||||
$this->projectRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Project[] $projects */
|
||||
$projects = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache customer objects instead of joining them
|
||||
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader->loadResults($projects);
|
||||
|
||||
return $projects;
|
||||
@@ -398,18 +404,17 @@ class ProjectStatisticService
|
||||
// fetch stats grouped by ACTIVITY for all time
|
||||
$qb1 = clone $qb;
|
||||
$qb1
|
||||
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
|
||||
->addSelect('a as activity')
|
||||
->addGroupBy('a')
|
||||
->addSelect('IDENTITY(t.activity) as activity')
|
||||
->addGroupBy('t.activity')
|
||||
;
|
||||
|
||||
/** @var array<ActivityStatistic> $activities */
|
||||
/** @var array<string, ActivityStatistic> $activities */
|
||||
$activities = [];
|
||||
foreach ($qb1->getQuery()->getResult() as $tmp) {
|
||||
$activityId = $tmp['activity']->getId();
|
||||
/** @var array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int} $tmp */
|
||||
foreach ($qb1->getQuery()->getArrayResult() as $tmp) {
|
||||
$activityId = $tmp['activity'];
|
||||
if (!\array_key_exists($activityId, $activities)) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activities[$activityId] = $activity;
|
||||
} else {
|
||||
$activity = $activities[$activityId];
|
||||
@@ -423,10 +428,27 @@ class ProjectStatisticService
|
||||
if ($tmp['billable']) {
|
||||
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
|
||||
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
|
||||
$activity->setInternalRateBillable($activity->getInternalRateBillable() + $tmp['internalRate']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activities as $activity) {
|
||||
/** @var array<string, Activity> $activityIdToActivity */
|
||||
$activityIdToActivity = [];
|
||||
|
||||
if (\count($activities) > 0) {
|
||||
// prepare activities for later use
|
||||
$qbActivity = $this->activityRepository->createQueryBuilder('a');
|
||||
$qbActivity->select('a')->where($qbActivity->expr()->in('a.id', array_keys($activities)));
|
||||
|
||||
/** @var array<int, Activity> $activityResults */
|
||||
$activityResults = $qbActivity->getQuery()->getResult();
|
||||
foreach ($activityResults as $item) {
|
||||
$activityIdToActivity[$item->getId()] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($activities as $activityId => $activity) {
|
||||
$activity->setActivity($activityIdToActivity[$activityId]);
|
||||
$model->addActivity($activity);
|
||||
}
|
||||
// ---------------------------------------------------
|
||||
@@ -450,7 +472,9 @@ class ProjectStatisticService
|
||||
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
|
||||
/** @var array<int, UserStatistic> $users */
|
||||
$users = [];
|
||||
foreach ($qb2->getQuery()->getResult() as $user) {
|
||||
/** @var array<int, User> $userResult */
|
||||
$userResult = $qb2->getQuery()->getResult();
|
||||
foreach ($userResult as $user) {
|
||||
$users[$user->getId()] = new UserStatistic($user);
|
||||
}
|
||||
|
||||
@@ -533,41 +557,33 @@ class ProjectStatisticService
|
||||
$tmp->setMonth(new Month($i));
|
||||
}
|
||||
$years[$year['year']] = $tmp;
|
||||
} else {
|
||||
$tmp = $years[$year['year']];
|
||||
}
|
||||
$tmp->setTotalRate($tmp->getTotalRate() + $year['rate']);
|
||||
$tmp->setTotalInternalRate($tmp->getTotalInternalRate() + $year['internalRate']);
|
||||
$tmp->setTotalDuration($tmp->getTotalDuration() + $year['duration']);
|
||||
|
||||
if ($year['billable']) {
|
||||
$tmp->setBillableDuration($tmp->getBillableDuration() + $year['duration']);
|
||||
$tmp->setBillableRate($tmp->getBillableRate() + $year['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string, array<string, ActivityStatistic>> $yearActivities */
|
||||
$yearActivities = [];
|
||||
foreach ($years as $yearName => $yearStat) {
|
||||
// fetch yearly stats grouped by ACTIVITY and YEAR
|
||||
$qb2 = clone $qb;
|
||||
$qb2
|
||||
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity')
|
||||
->addSelect('a as activity')
|
||||
->addSelect('IDENTITY(t.activity) as activity')
|
||||
->addSelect('YEAR(t.date) as year')
|
||||
->andWhere('YEAR(t.date) = :year')
|
||||
->setParameter('year', $yearName)
|
||||
->addGroupBy('year')
|
||||
->addGroupBy('a')
|
||||
->addGroupBy('t.activity')
|
||||
;
|
||||
|
||||
foreach ($qb2->getQuery()->getResult() as $tmp) {
|
||||
$activityId = $tmp['activity']->getId();
|
||||
/** @var array<int, array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int, "year": int}> $statsTmp */
|
||||
$statsTmp = $qb2->getQuery()->getArrayResult();
|
||||
foreach ($statsTmp as $tmp) {
|
||||
$activityId = $tmp['activity'];
|
||||
if (!\array_key_exists($yearName, $yearActivities)) {
|
||||
$yearActivities[$yearName] = [];
|
||||
}
|
||||
if (!\array_key_exists($activityId, $yearActivities[$yearName])) {
|
||||
$activity = new ActivityStatistic();
|
||||
$activity->setActivity($tmp['activity']);
|
||||
$activity->setActivity($activityIdToActivity[$activityId]);
|
||||
$yearActivities[$yearName][$activityId] = $activity;
|
||||
} else {
|
||||
$activity = $yearActivities[$yearName][$activityId];
|
||||
@@ -580,14 +596,14 @@ class ProjectStatisticService
|
||||
if ($tmp['billable']) {
|
||||
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
|
||||
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
|
||||
$activity->setInternalRateBillable($activity->getInternalRateBillable() + $tmp['internalRate']);
|
||||
}
|
||||
$model->addYearActivity($tmp['year'], $activity);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($yearActivities as $year => $yearlyActivities) {
|
||||
foreach ($yearlyActivities as $activity) {
|
||||
$model->addYearActivity($year, $activity);
|
||||
foreach ($yearlyActivities as $activityId => $activityStatistic) {
|
||||
$model->addYearActivity($year, $activityStatistic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +647,7 @@ class ProjectStatisticService
|
||||
$user = $query->getUser();
|
||||
$today = clone $query->getToday();
|
||||
|
||||
$qb = $this->repository->createQueryBuilder('p');
|
||||
$qb = $this->projectRepository->createQueryBuilder('p');
|
||||
$qb
|
||||
->select('p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
@@ -675,13 +691,13 @@ class ProjectStatisticService
|
||||
);
|
||||
}
|
||||
|
||||
$this->repository->addPermissionCriteria($qb, $user);
|
||||
$this->projectRepository->addPermissionCriteria($qb, $user);
|
||||
|
||||
/** @var Project[] $projects */
|
||||
$projects = $qb->getQuery()->getResult();
|
||||
|
||||
// pre-cache customer objects instead of joining them
|
||||
$loader = new ProjectLoader($this->repository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
|
||||
$loader->loadResults($projects);
|
||||
|
||||
return $projects;
|
||||
|
||||
@@ -95,7 +95,9 @@ final class ProjectDetailsModel
|
||||
$users[$id] = $userStat;
|
||||
}
|
||||
$userStat->setDuration($userStat->getDuration() + $userYear->getDuration());
|
||||
$userStat->setDurationBillable($userStat->getDurationBillable() + $userYear->getBillableDuration());
|
||||
$userStat->setRate($userStat->getRate() + $userYear->getRate());
|
||||
$userStat->setRateBillable($userStat->getRateBillable() + $userYear->getBillableRate());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 table-responsive">
|
||||
<table class="table table-hover dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'username'|trans }}</th>
|
||||
<th></th>
|
||||
@@ -152,6 +153,8 @@
|
||||
<th class="text-center">{{ monthName }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set yearTotal = 0 %}
|
||||
{% for userYearStat in userYearStats|sort((a, b) => b.duration <=> a.duration) %}
|
||||
{% set user = userYearStat.user %}
|
||||
@@ -175,11 +178,13 @@
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-nowrap text-center total">
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary">
|
||||
<td></td>
|
||||
<td class="text-nowrap text-center total">
|
||||
{{ yearTotal|duration }}
|
||||
</th>
|
||||
</td>
|
||||
{% for monthNumber in 1..12 %}
|
||||
{% set total = 0 %}
|
||||
{% for userYearStat in userYearStats %}
|
||||
@@ -188,11 +193,12 @@
|
||||
{% set total = total + month.duration %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<th class="text-nowrap text-center total">
|
||||
<td class="text-nowrap text-center total">
|
||||
{{ total|duration }}
|
||||
</th>
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,8 +217,25 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-nowrap text-end">{{ 'duration'|trans }}</th>
|
||||
{% if view_revenue %}
|
||||
<th class="text-nowrap text-end">{{ 'billable'|trans }}</th>
|
||||
<th class="text-nowrap text-end">{{ 'stats.amountTotal'|trans }}</th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set totalRate = 0.0 %}
|
||||
{% set billable = 0 %}
|
||||
{% for stat in activities|sort((a, b) => b.duration <=> a.duration) %}
|
||||
{% set dataset = dataset|merge([{'value': stat.duration, 'name': stat.name, 'color': stat.activity.color|colorize(stat.activity.name), 'duration': stat.duration|duration, 'rate': stat.rate|money(currency)}]) %}
|
||||
{% set rate = stat.rate %}
|
||||
{% set totalRate = totalRate + rate %}
|
||||
{% set billable = billable + stat.rateBillable %}
|
||||
{% set dataset = dataset|merge([{'value': stat.duration, 'name': stat.name, 'color': stat.activity.color|colorize(stat.activity.name), 'duration': stat.duration|duration, 'rate': rate|money(currency)}]) %}
|
||||
{% set percentage = 0 %}
|
||||
{% if totalDuration > 0 and stat.duration > 0 %}
|
||||
{% set percentage = (100 / (totalDuration / stat.duration)) %}
|
||||
@@ -221,11 +244,24 @@
|
||||
<td>{{ widgets.label_activity(stat.activity, {'inherit': false, 'random': true}) }}</td>
|
||||
<td class="text-nowrap text-end">{{ stat.duration|duration }}</td>
|
||||
{% if view_revenue %}
|
||||
<td class="text-nowrap text-end">{{ stat.rateBillable|money(currency) }}</td>
|
||||
<td class="text-nowrap text-end">{{ stat.rate|money(currency) }}</td>
|
||||
{% endif %}
|
||||
<td class="text-nowrap text-end">{{ percentage|number_format(1) }} %</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary">
|
||||
<td></td>
|
||||
<td class="text-nowrap text-end">{{ totalDuration|duration }}</td>
|
||||
{% if view_revenue %}
|
||||
<td class="text-nowrap text-end">{{ billable|money(currency) }}</td>
|
||||
<td class="text-nowrap text-end">{{ totalRate|money(currency) }}</td>
|
||||
{% endif %}
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">
|
||||
@@ -430,7 +466,20 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
|
||||
<table class="table table-hover dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="text-nowrap text-end">{{ 'duration'|trans }}</th>
|
||||
{% if view_revenue %}
|
||||
<th class="text-nowrap text-end">{{ 'billable'|trans }}</th>
|
||||
<th class="text-nowrap text-end">{{ 'stats.amountTotal'|trans }}</th>
|
||||
{% endif %}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set rateTotal = 0 %}
|
||||
{% set rateTotalBillable = 0 %}
|
||||
{% set totalDuration = 0 %}
|
||||
{% set datasets = [] %}
|
||||
{% set labels = [] %}
|
||||
@@ -438,6 +487,7 @@
|
||||
{% set user = userStat.user %}
|
||||
{% set color = user.color|colorize(user.displayName) %}
|
||||
{% set rateTotal = rateTotal + userStat.rate %}
|
||||
{% set rateTotalBillable = rateTotalBillable + userStat.rateBillable %}
|
||||
{% set totalDuration = totalDuration + userStat.duration %}
|
||||
{% set datasets = datasets|merge([{'name': user.displayName, 'duration': userStat.duration|duration, 'value': userStat.duration, 'color': color, 'rate': userStat.rate|money(currency)}]) %}
|
||||
{% set percentage = 0 %}
|
||||
@@ -452,6 +502,9 @@
|
||||
{{ userStat.duration|duration }}
|
||||
</td>
|
||||
{% if view_revenue %}
|
||||
<td class="text-nowrap text-end">
|
||||
{{ userStat.rateBillable|money(currency) }}
|
||||
</td>
|
||||
<td class="text-nowrap text-end">
|
||||
{{ userStat.rate|money(currency) }}
|
||||
</td>
|
||||
@@ -461,14 +514,18 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary">
|
||||
<td></td>
|
||||
<th class="text-nowrap text-end total">{{ totalDuration|duration }}</th>
|
||||
<td class="text-nowrap text-end total">{{ totalDuration|duration }}</td>
|
||||
{% if view_revenue %}
|
||||
<th class="text-nowrap text-end total">{{ rateTotal|money(currency) }}</th>
|
||||
<td class="text-nowrap text-end total">{{ rateTotalBillable|money(currency) }}</td>
|
||||
<td class="text-nowrap text-end total">{{ rateTotal|money(currency) }}</td>
|
||||
{% endif %}
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">
|
||||
|
||||
@@ -288,7 +288,7 @@ class ConfigurationTest extends TestCase
|
||||
'allow_overbooking_budget' => true,
|
||||
'lockdown_period_timezone' => null,
|
||||
'break_warning_duration' => 0,
|
||||
'long_running_duration' => 480,
|
||||
'long_running_duration' => 600,
|
||||
'require_activity' => true,
|
||||
],
|
||||
'duration_increment' => 15,
|
||||
|
||||
@@ -53,6 +53,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
|
||||
'entry.total_plain',
|
||||
'entry.currency',
|
||||
'entry.duration',
|
||||
'entry.duration_format',
|
||||
'entry.duration_decimal',
|
||||
'entry.duration_minutes',
|
||||
'entry.begin',
|
||||
|
||||
@@ -260,6 +260,7 @@ class DebugRendererTest extends TestCase
|
||||
'entry.total_plain',
|
||||
'entry.currency',
|
||||
'entry.duration',
|
||||
'entry.duration_format',
|
||||
'entry.duration_decimal',
|
||||
'entry.duration_minutes',
|
||||
'entry.begin',
|
||||
|
||||
@@ -11,36 +11,51 @@ namespace App\Tests\Model\Statistic;
|
||||
|
||||
use App\Model\Statistic\Month;
|
||||
use App\Model\Statistic\Year;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Model\Statistic\Year
|
||||
*/
|
||||
class YearTest extends AbstractTimesheetTest
|
||||
class YearTest extends TestCase
|
||||
{
|
||||
public function testDefaultValues()
|
||||
public function testDefaultValues(): void
|
||||
{
|
||||
$sut = new Year('1999');
|
||||
$this->assertDefaultValues($sut);
|
||||
|
||||
self::assertNull($sut->getMonth(1));
|
||||
self::assertEmpty($sut->getMonths());
|
||||
self::assertIsArray($sut->getMonths());
|
||||
self::assertEquals('1999', $sut->getYear());
|
||||
self::assertSame(0, $sut->getBillableDuration());
|
||||
self::assertSame(0.0, $sut->getRate());
|
||||
self::assertSame(0.0, $sut->getBillableRate());
|
||||
self::assertSame(0, $sut->getDuration());
|
||||
self::assertSame(0, $sut->getBillableDuration());
|
||||
}
|
||||
|
||||
public function testSetter()
|
||||
public function testSetter(): void
|
||||
{
|
||||
$sut = new Year('1999');
|
||||
$this->assertSetter($sut);
|
||||
|
||||
$sut->setMonth(new Month('01'));
|
||||
$sut->setMonth(new Month('02'));
|
||||
$sut->setMonth(new Month('03'));
|
||||
$january = new Month('01');
|
||||
$january->setBillableDuration(1234);
|
||||
$january->setBillableRate(4321.12);
|
||||
$january->setTotalDuration(2345);
|
||||
$january->setTotalRate(5555.55);
|
||||
|
||||
$february = new Month('02');
|
||||
$february->setBillableDuration(1234);
|
||||
$february->setBillableRate(4321.12);
|
||||
|
||||
$march = new Month('03');
|
||||
$march->setBillableDuration(1);
|
||||
$march->setBillableRate(1.1);
|
||||
|
||||
$sut->setMonth($january);
|
||||
$sut->setMonth($february);
|
||||
$sut->setMonth($march);
|
||||
self::assertEquals(3, \count($sut->getMonths()));
|
||||
|
||||
$sut->setMonth(new Month('01'));
|
||||
$sut->setMonth(new Month('02'));
|
||||
|
||||
self::assertEquals(3, \count($sut->getMonths()));
|
||||
|
||||
@@ -49,9 +64,9 @@ class YearTest extends AbstractTimesheetTest
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(3));
|
||||
self::assertNull($sut->getMonth(4));
|
||||
|
||||
$sut->setBillableDuration(123456);
|
||||
$sut->setBillableRate(123.456789);
|
||||
self::assertSame(123456, $sut->getBillableDuration());
|
||||
self::assertSame(123.456789, $sut->getBillableRate());
|
||||
self::assertSame(1235, $sut->getBillableDuration());
|
||||
self::assertSame(2345, $sut->getDuration());
|
||||
self::assertSame(4322.22, $sut->getBillableRate());
|
||||
self::assertSame(5555.55, $sut->getRate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7917,16 +7917,6 @@ parameters:
|
||||
count: 1
|
||||
path: Model/Statistic/TimesheetTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Model\\\\Statistic\\\\YearTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Model/Statistic/YearTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Model\\\\Statistic\\\\YearTest\\:\\:testSetter\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Model/Statistic/YearTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Model\\\\TimesheetCountedStatisticTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- Reports -->
|
||||
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
|
||||
<source>report_tasks_assigned</source>
|
||||
<target>Meine Aufgaben</target>
|
||||
<target>Aufgaben pro Benutzer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
|
||||
<source>report_tasks_teams</source>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<!-- Reports -->
|
||||
<trans-unit id="tLV1hoP" resname="report_tasks_assigned">
|
||||
<source>report_tasks_assigned</source>
|
||||
<target>My tasks</target>
|
||||
<target>Tasks per user</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="8c0YWZm" resname="report_tasks_teams">
|
||||
<source>report_tasks_teams</source>
|
||||
|
||||
Reference in New Issue
Block a user