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:
Kevin Papst
2023-05-15 22:30:37 +02:00
committed by GitHub
parent 04422f3530
commit 29624354a2
28 changed files with 248 additions and 174 deletions

View File

@@ -73,6 +73,7 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
let options = { let options = {
lockOptgroupOrder: true, lockOptgroupOrder: true,
allowEmptyOption: !isRequired, allowEmptyOption: !isRequired,
hidePlaceholder: false,
plugins: plugins, plugins: plugins,
// if there are more than X entries, the other ones are hidden and can only be found // 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 // by typing some characters to trigger the internal option search

View File

@@ -79,15 +79,6 @@ table.dataTable thead > tr > th.hw-min {
max-height: 340px; 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, #customer_details_box,
#project_details_box, #project_details_box,
#activity_details_box { #activity_details_box {

View File

@@ -34,7 +34,7 @@
"friendsofsymfony/rest-bundle": "^3.0", "friendsofsymfony/rest-bundle": "^3.0",
"gedmo/doctrine-extensions": "^3.6", "gedmo/doctrine-extensions": "^3.6",
"jms/serializer-bundle": "^5.0", "jms/serializer-bundle": "^5.0",
"kevinpapst/tabler-bundle": "^0.16", "kevinpapst/tabler-bundle": "^0.17",
"league/csv": "^9.4", "league/csv": "^9.4",
"mpdf/mpdf": "^8.0", "mpdf/mpdf": "^8.0",
"nelmio/api-doc-bundle": "^4.0", "nelmio/api-doc-bundle": "^4.0",

14
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "26d6641fd4c8f977b0a50cab05e1df87", "content-hash": "63b69c5c8020a78b308210425547d46c",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -2416,16 +2416,16 @@
}, },
{ {
"name": "kevinpapst/tabler-bundle", "name": "kevinpapst/tabler-bundle",
"version": "0.16", "version": "0.17",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/kevinpapst/TablerBundle.git", "url": "https://github.com/kevinpapst/TablerBundle.git",
"reference": "c5986daa3595fc5163f7d0cb15f69dc5f696b987" "reference": "0061d82a28059cf876cf819f1780607aac131d32"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/c5986daa3595fc5163f7d0cb15f69dc5f696b987", "url": "https://api.github.com/repos/kevinpapst/TablerBundle/zipball/0061d82a28059cf876cf819f1780607aac131d32",
"reference": "c5986daa3595fc5163f7d0cb15f69dc5f696b987", "reference": "0061d82a28059cf876cf819f1780607aac131d32",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2474,7 +2474,7 @@
"description": "Admin/Backend theme bundle for Symfony based on Tabler.io", "description": "Admin/Backend theme bundle for Symfony based on Tabler.io",
"support": { "support": {
"issues": "https://github.com/kevinpapst/TablerBundle/issues", "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": [ "funding": [
{ {
@@ -2486,7 +2486,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2023-05-15T12:01:30+00:00" "time": "2023-05-15T18:25:59+00:00"
}, },
{ {
"name": "laminas/laminas-escaper", "name": "laminas/laminas-escaper",

View File

@@ -4836,11 +4836,6 @@ parameters:
count: 1 count: 1
path: src/Plugin/PluginMetadata.php 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\\.$#" message: "#^Cannot access offset 'amount' on mixed\\.$#"
count: 1 count: 1
@@ -4881,16 +4876,6 @@ parameters:
count: 2 count: 2
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Cannot call method getMonth\\(\\) on App\\\\Model\\\\Statistic\\\\Year\\|null\\.$#"
count: 1 count: 1
@@ -4911,24 +4896,14 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Parameter \\#1 \\$billableDuration of method App\\\\Model\\\\Statistic\\\\Month\\:\\:setBillableDuration\\(\\) expects int, float\\|int given\\.$#"
count: 2 count: 2
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Parameter \\#1 \\$counter of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setCounter\\(\\) expects int, float\\|int given\\.$#"
count: 3 count: 1
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Parameter \\#1 \\$duration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDuration\\(\\) expects int, float\\|int given\\.$#"
count: 3 count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
- -
@@ -4971,11 +4946,6 @@ parameters:
count: 1 count: 1
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Parameter \\#1 \\$project of class App\\\\Reporting\\\\ProjectDetails\\\\ProjectDetailsModel constructor expects App\\\\Entity\\\\Project, App\\\\Entity\\\\Project\\|null given\\.$#"
count: 1 count: 1
@@ -4993,7 +4963,7 @@ parameters:
- -
message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#" message: "#^Parameter \\#1 \\$recordDuration of method App\\\\Model\\\\TimesheetCountedStatistic\\:\\:setDurationBillable\\(\\) expects int, float\\|int given\\.$#"
count: 3 count: 1
path: src/Project/ProjectStatisticService.php 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\\.$#" message: "#^Parameter \\#1 \\$totalDuration of method App\\\\Model\\\\Statistic\\\\Timesheet\\:\\:setTotalDuration\\(\\) expects int, float\\|int given\\.$#"
count: 3 count: 2
path: src/Project/ProjectStatisticService.php
-
message: "#^Parameter \\#1 \\$user of class App\\\\Model\\\\UserStatistic constructor expects App\\\\Entity\\\\User, mixed given\\.$#"
count: 1
path: src/Project/ProjectStatisticService.php path: src/Project/ProjectStatisticService.php
- -

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": { "app": {
"js": [ "js": [
"/build/runtime.f0079159.js", "/build/runtime.f0079159.js",
"/build/app.bed7a1bf.js" "/build/app.45d541e0.js"
], ],
"css": [ "css": [
"/build/app.80fb16e8.css" "/build/app.66162a27.css"
] ]
}, },
"export-pdf": { "export-pdf": {
@@ -63,8 +63,8 @@
}, },
"integrity": { "integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV", "/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.bed7a1bf.js": "sha384-OCg9guQNr23augSqlruX0IK2FWrKO1MWMx0sBRIzAkKgg561U4stZRHfxQbrwJeG", "/build/app.45d541e0.js": "sha384-KhCwP7jLZJ8+g02Lr+oITVZ8woE+EU01t/VQNuDyHmpVPCVggaaoI9EU9lJeB62l",
"/build/app.80fb16e8.css": "sha384-kDryvDuNVyJAdIyQEuF5NpT1duQ4VmScmUNtha3B0ssQyzLuQdWBz/G3m70wao12", "/build/app.66162a27.css": "sha384-TeR8dC4yDJMVshAZ+sa3WotB4RqTHOiTtHROHg7jXEE29WCabeBlXB6MKiPdnsu3",
"/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u", "/build/export-pdf.d367a32e.js": "sha384-Z5baqnzjI636nYFs4g63ViIKBZKRW4Jhv/7PQmTEQlqhfA7eK0vUMUtiyy0R5A9u",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.2604495e.js": "sha384-D6JvhGSqlx7z72b/qD3nF3QDXPy+XsCSRGtWfs1icjDKOcd2UzuXwuSa/E1Fg2TJ", "/build/invoice.2604495e.js": "sha384-D6JvhGSqlx7z72b/qD3nF3QDXPy+XsCSRGtWfs1icjDKOcd2UzuXwuSa/E1Fg2TJ",

View File

@@ -1,6 +1,6 @@
{ {
"build/app.css": "/build/app.80fb16e8.css", "build/app.css": "/build/app.66162a27.css",
"build/app.js": "/build/app.bed7a1bf.js", "build/app.js": "/build/app.45d541e0.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.d367a32e.js", "build/export-pdf.js": "/build/export-pdf.d367a32e.js",
"build/invoice.css": "/build/invoice.5bea118e.css", "build/invoice.css": "/build/invoice.5bea118e.css",

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.0.19'; public const VERSION = '2.0.20';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20019; public const VERSION_ID = 20020;
/** /**
* The software name * The software name
*/ */

View File

@@ -305,7 +305,7 @@ final class Configuration implements ConfigurationInterface
->defaultValue(0) ->defaultValue(0)
->end() ->end()
->integerNode('long_running_duration') ->integerNode('long_running_duration')
->defaultValue(480) ->defaultValue(600)
->end() ->end()
->booleanNode('require_activity') ->booleanNode('require_activity')
->defaultTrue() ->defaultTrue()

View File

@@ -72,6 +72,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.total_plain' => $rate, 'entry.total_plain' => $rate,
'entry.currency' => $currency, 'entry.currency' => $currency,
'entry.duration' => $item->getDuration(), 'entry.duration' => $item->getDuration(),
'entry.duration_format' => $formatter->getFormattedDuration($item->getDuration()),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()), 'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => (int) ($item->getDuration() / 60), 'entry.duration_minutes' => (int) ($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin), 'entry.begin' => $formatter->getFormattedDateTime($begin),

View File

@@ -17,8 +17,7 @@ class Timesheet
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
* * @see getDuration()
* @return int
*/ */
public function getValue(): int public function getValue(): int
{ {
@@ -27,8 +26,7 @@ class Timesheet
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
* * @see getTotalDuration()
* @return int
*/ */
public function getDuration(): int public function getDuration(): int
{ {
@@ -47,8 +45,7 @@ class Timesheet
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
* * @see getTotalRate()
* @return float
*/ */
public function getRate(): float public function getRate(): float
{ {
@@ -67,8 +64,7 @@ class Timesheet
/** /**
* For unified access, used in frontend. * For unified access, used in frontend.
* * @see getTotalInternalRate()
* @return float
*/ */
public function getInternalRate(): float public function getInternalRate(): float
{ {

View File

@@ -29,21 +29,26 @@ final class UserYear
public function getDuration(): int public function getDuration(): int
{ {
$duration = 0; return $this->year->getDuration();
foreach ($this->year->getMonths() as $month) {
$duration += $month->getDuration();
} }
return $duration; public function getBillableDuration(): int
{
return $this->year->getBillableDuration();
} }
public function getRate(): float public function getRate(): float
{ {
$rate = 0; return $this->year->getRate();
foreach ($this->year->getMonths() as $month) {
$rate += $month->getRate();
} }
return $rate; public function getBillableRate(): float
{
return $this->year->getBillableRate();
}
public function getInternalRate(): float
{
return $this->year->getInternalRate();
} }
} }

View File

@@ -9,14 +9,12 @@
namespace App\Model\Statistic; namespace App\Model\Statistic;
final class Year extends Timesheet final class Year
{ {
/** /**
* @var Month[] * @var Month[]
*/ */
private array $months = []; private array $months = [];
private int $billableDuration = 0;
private float $billableRate = 0.00;
public function __construct(private string $year) public function __construct(private string $year)
{ {
@@ -51,23 +49,58 @@ final class Year extends Timesheet
return array_values($this->months); 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();
} }
public function setBillableDuration(int $billableDuration): void return $duration;
}
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 public function getBillableRate(): float
{ {
return $this->billableRate; $rate = 0.0;
foreach ($this->months as $month) {
$rate += $month->getBillableRate();
} }
public function setBillableRate(float $billableRate): void return $rate;
}
public function getInternalRate(): float
{ {
$this->billableRate = $billableRate; $rate = 0.0;
foreach ($this->months as $month) {
$rate += $month->getInternalRate();
}
return $rate;
} }
} }

View File

@@ -28,6 +28,7 @@ use App\Reporting\ProjectDetails\ProjectDetailsQuery;
use App\Reporting\ProjectInactive\ProjectInactiveQuery; use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel; use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery; use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ProjectLoader; use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
@@ -35,7 +36,6 @@ use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory; use App\Timesheet\DateTimeFactory;
use DateTime; use DateTime;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query\Expr\Join;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/** /**
@@ -43,7 +43,13 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
*/ */
class ProjectStatisticService 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(); $lastChange = clone $query->getLastChange();
$now = new DateTime('now', $lastChange->getTimezone()); $now = new DateTime('now', $lastChange->getTimezone());
$qb2 = $this->repository->createQueryBuilder('t1'); $qb2 = $this->projectRepository->createQueryBuilder('t1');
$qb2 $qb2
->select('1') ->select('1')
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
@@ -82,7 +88,7 @@ class ProjectStatisticService
->andWhere($qb2->expr()->gte('t.begin', ':begin')) ->andWhere($qb2->expr()->gte('t.begin', ':begin'))
; ;
$qb = $this->repository->createQueryBuilder('p'); $qb = $this->projectRepository->createQueryBuilder('p');
$qb $qb
->select('p, c') ->select('p, c')
->leftJoin('p.customer', 'c') ->leftJoin('p.customer', 'c')
@@ -99,13 +105,13 @@ class ProjectStatisticService
->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE) ->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE)
; ;
$this->repository->addPermissionCriteria($qb, $user); $this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */ /** @var Project[] $projects */
$projects = $qb->getQuery()->getResult(); $projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them // 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); $loader->loadResults($projects);
return $projects; return $projects;
@@ -121,7 +127,7 @@ class ProjectStatisticService
$begin = $dateRange->getBegin(); $begin = $dateRange->getBegin();
$end = $dateRange->getEnd(); $end = $dateRange->getEnd();
$qb = $this->repository->createQueryBuilder('p'); $qb = $this->projectRepository->createQueryBuilder('p');
$qb $qb
->select('p') ->select('p')
->leftJoin('p.customer', 'c') ->leftJoin('p.customer', 'c')
@@ -144,7 +150,7 @@ class ProjectStatisticService
; ;
if (!$query->isIncludeNoWork()) { if (!$query->isIncludeNoWork()) {
$qb2 = $this->repository->createQueryBuilder('t1'); $qb2 = $this->projectRepository->createQueryBuilder('t1');
$qb2 $qb2
->select('1') ->select('1')
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
@@ -183,13 +189,13 @@ class ProjectStatisticService
->setParameter('customer', $query->getCustomer()); ->setParameter('customer', $query->getCustomer());
} }
$this->repository->addPermissionCriteria($qb, $user); $this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */ /** @var Project[] $projects */
$projects = $qb->getQuery()->getResult(); $projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them // 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); $loader->loadResults($projects);
return $projects; return $projects;
@@ -398,18 +404,17 @@ class ProjectStatisticService
// fetch stats grouped by ACTIVITY for all time // fetch stats grouped by ACTIVITY for all time
$qb1 = clone $qb; $qb1 = clone $qb;
$qb1 $qb1
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity') ->addSelect('IDENTITY(t.activity) as activity')
->addSelect('a as activity') ->addGroupBy('t.activity')
->addGroupBy('a')
; ;
/** @var array<ActivityStatistic> $activities */ /** @var array<string, ActivityStatistic> $activities */
$activities = []; $activities = [];
foreach ($qb1->getQuery()->getResult() as $tmp) { /** @var array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int} $tmp */
$activityId = $tmp['activity']->getId(); foreach ($qb1->getQuery()->getArrayResult() as $tmp) {
$activityId = $tmp['activity'];
if (!\array_key_exists($activityId, $activities)) { if (!\array_key_exists($activityId, $activities)) {
$activity = new ActivityStatistic(); $activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']);
$activities[$activityId] = $activity; $activities[$activityId] = $activity;
} else { } else {
$activity = $activities[$activityId]; $activity = $activities[$activityId];
@@ -423,10 +428,27 @@ class ProjectStatisticService
if ($tmp['billable']) { if ($tmp['billable']) {
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']); $activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']); $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); $model->addActivity($activity);
} }
// --------------------------------------------------- // ---------------------------------------------------
@@ -450,7 +472,9 @@ class ProjectStatisticService
$qb2->select('u')->where($qb2->expr()->in('u.id', $userIds)); $qb2->select('u')->where($qb2->expr()->in('u.id', $userIds));
/** @var array<int, UserStatistic> $users */ /** @var array<int, UserStatistic> $users */
$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); $users[$user->getId()] = new UserStatistic($user);
} }
@@ -533,41 +557,33 @@ class ProjectStatisticService
$tmp->setMonth(new Month($i)); $tmp->setMonth(new Month($i));
} }
$years[$year['year']] = $tmp; $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 = []; $yearActivities = [];
foreach ($years as $yearName => $yearStat) { foreach ($years as $yearName => $yearStat) {
// fetch yearly stats grouped by ACTIVITY and YEAR // fetch yearly stats grouped by ACTIVITY and YEAR
$qb2 = clone $qb; $qb2 = clone $qb;
$qb2 $qb2
->leftJoin(Activity::class, 'a', Join::WITH, 'a.id = t.activity') ->addSelect('IDENTITY(t.activity) as activity')
->addSelect('a as activity')
->addSelect('YEAR(t.date) as year') ->addSelect('YEAR(t.date) as year')
->andWhere('YEAR(t.date) = :year') ->andWhere('YEAR(t.date) = :year')
->setParameter('year', $yearName) ->setParameter('year', $yearName)
->addGroupBy('year') ->addGroupBy('year')
->addGroupBy('a') ->addGroupBy('t.activity')
; ;
foreach ($qb2->getQuery()->getResult() as $tmp) { /** @var array<int, array{"duration": int, "rate": float, "internalRate": float, "count": int, "billable": bool, "activity": int, "year": int}> $statsTmp */
$activityId = $tmp['activity']->getId(); $statsTmp = $qb2->getQuery()->getArrayResult();
foreach ($statsTmp as $tmp) {
$activityId = $tmp['activity'];
if (!\array_key_exists($yearName, $yearActivities)) { if (!\array_key_exists($yearName, $yearActivities)) {
$yearActivities[$yearName] = []; $yearActivities[$yearName] = [];
} }
if (!\array_key_exists($activityId, $yearActivities[$yearName])) { if (!\array_key_exists($activityId, $yearActivities[$yearName])) {
$activity = new ActivityStatistic(); $activity = new ActivityStatistic();
$activity->setActivity($tmp['activity']); $activity->setActivity($activityIdToActivity[$activityId]);
$yearActivities[$yearName][$activityId] = $activity; $yearActivities[$yearName][$activityId] = $activity;
} else { } else {
$activity = $yearActivities[$yearName][$activityId]; $activity = $yearActivities[$yearName][$activityId];
@@ -580,14 +596,14 @@ class ProjectStatisticService
if ($tmp['billable']) { if ($tmp['billable']) {
$activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']); $activity->setDurationBillable($activity->getDurationBillable() + $tmp['duration']);
$activity->setRateBillable($activity->getRateBillable() + $tmp['rate']); $activity->setRateBillable($activity->getRateBillable() + $tmp['rate']);
$activity->setInternalRateBillable($activity->getInternalRateBillable() + $tmp['internalRate']);
} }
$model->addYearActivity($tmp['year'], $activity);
} }
} }
foreach ($yearActivities as $year => $yearlyActivities) { foreach ($yearActivities as $year => $yearlyActivities) {
foreach ($yearlyActivities as $activity) { foreach ($yearlyActivities as $activityId => $activityStatistic) {
$model->addYearActivity($year, $activity); $model->addYearActivity($year, $activityStatistic);
} }
} }
@@ -631,7 +647,7 @@ class ProjectStatisticService
$user = $query->getUser(); $user = $query->getUser();
$today = clone $query->getToday(); $today = clone $query->getToday();
$qb = $this->repository->createQueryBuilder('p'); $qb = $this->projectRepository->createQueryBuilder('p');
$qb $qb
->select('p') ->select('p')
->leftJoin('p.customer', 'c') ->leftJoin('p.customer', 'c')
@@ -675,13 +691,13 @@ class ProjectStatisticService
); );
} }
$this->repository->addPermissionCriteria($qb, $user); $this->projectRepository->addPermissionCriteria($qb, $user);
/** @var Project[] $projects */ /** @var Project[] $projects */
$projects = $qb->getQuery()->getResult(); $projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them // 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); $loader->loadResults($projects);
return $projects; return $projects;

View File

@@ -95,7 +95,9 @@ final class ProjectDetailsModel
$users[$id] = $userStat; $users[$id] = $userStat;
} }
$userStat->setDuration($userStat->getDuration() + $userYear->getDuration()); $userStat->setDuration($userStat->getDuration() + $userYear->getDuration());
$userStat->setDurationBillable($userStat->getDurationBillable() + $userYear->getBillableDuration());
$userStat->setRate($userStat->getRate() + $userYear->getRate()); $userStat->setRate($userStat->getRate() + $userYear->getRate());
$userStat->setRateBillable($userStat->getRateBillable() + $userYear->getBillableRate());
} }
} }

View File

@@ -145,6 +145,7 @@
<div class="row"> <div class="row">
<div class="col-xs-12 table-responsive"> <div class="col-xs-12 table-responsive">
<table class="table table-hover dataTable"> <table class="table table-hover dataTable">
<thead>
<tr> <tr>
<th>{{ 'username'|trans }}</th> <th>{{ 'username'|trans }}</th>
<th></th> <th></th>
@@ -152,6 +153,8 @@
<th class="text-center">{{ monthName }}</th> <th class="text-center">{{ monthName }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
</thead>
<tbody>
{% set yearTotal = 0 %} {% set yearTotal = 0 %}
{% for userYearStat in userYearStats|sort((a, b) => b.duration <=> a.duration) %} {% for userYearStat in userYearStats|sort((a, b) => b.duration <=> a.duration) %}
{% set user = userYearStat.user %} {% set user = userYearStat.user %}
@@ -175,11 +178,13 @@
{% endfor %} {% endfor %}
</tr> </tr>
{% endfor %} {% endfor %}
<tr> </tbody>
<th></th> <tfoot>
<th class="text-nowrap text-center total"> <tr class="summary">
<td></td>
<td class="text-nowrap text-center total">
{{ yearTotal|duration }} {{ yearTotal|duration }}
</th> </td>
{% for monthNumber in 1..12 %} {% for monthNumber in 1..12 %}
{% set total = 0 %} {% set total = 0 %}
{% for userYearStat in userYearStats %} {% for userYearStat in userYearStats %}
@@ -188,11 +193,12 @@
{% set total = total + month.duration %} {% set total = total + month.duration %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<th class="text-nowrap text-center total"> <td class="text-nowrap text-center total">
{{ total|duration }} {{ total|duration }}
</th> </td>
{% endfor %} {% endfor %}
</tr> </tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
@@ -211,8 +217,25 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6"> <div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
<table class="table table-hover dataTable"> <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) %} {% 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 %} {% set percentage = 0 %}
{% if totalDuration > 0 and stat.duration > 0 %} {% if totalDuration > 0 and stat.duration > 0 %}
{% set percentage = (100 / (totalDuration / stat.duration)) %} {% set percentage = (100 / (totalDuration / stat.duration)) %}
@@ -221,11 +244,24 @@
<td>{{ widgets.label_activity(stat.activity, {'inherit': false, 'random': true}) }}</td> <td>{{ widgets.label_activity(stat.activity, {'inherit': false, 'random': true}) }}</td>
<td class="text-nowrap text-end">{{ stat.duration|duration }}</td> <td class="text-nowrap text-end">{{ stat.duration|duration }}</td>
{% if view_revenue %} {% 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> <td class="text-nowrap text-end">{{ stat.rate|money(currency) }}</td>
{% endif %} {% endif %}
<td class="text-nowrap text-end">{{ percentage|number_format(1) }} %</td> <td class="text-nowrap text-end">{{ percentage|number_format(1) }} %</td>
</tr> </tr>
{% endfor %} {% 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> </table>
</div> </div>
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6"> <div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">
@@ -430,7 +466,20 @@
<div class="row"> <div class="row">
<div class="col-xs-12 col-sm-9 col-md-8 col-lg-6"> <div class="col-xs-12 col-sm-9 col-md-8 col-lg-6">
<table class="table table-hover dataTable"> <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 rateTotal = 0 %}
{% set rateTotalBillable = 0 %}
{% set totalDuration = 0 %} {% set totalDuration = 0 %}
{% set datasets = [] %} {% set datasets = [] %}
{% set labels = [] %} {% set labels = [] %}
@@ -438,6 +487,7 @@
{% set user = userStat.user %} {% set user = userStat.user %}
{% set color = user.color|colorize(user.displayName) %} {% set color = user.color|colorize(user.displayName) %}
{% set rateTotal = rateTotal + userStat.rate %} {% set rateTotal = rateTotal + userStat.rate %}
{% set rateTotalBillable = rateTotalBillable + userStat.rateBillable %}
{% set totalDuration = totalDuration + userStat.duration %} {% 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 datasets = datasets|merge([{'name': user.displayName, 'duration': userStat.duration|duration, 'value': userStat.duration, 'color': color, 'rate': userStat.rate|money(currency)}]) %}
{% set percentage = 0 %} {% set percentage = 0 %}
@@ -452,6 +502,9 @@
{{ userStat.duration|duration }} {{ userStat.duration|duration }}
</td> </td>
{% if view_revenue %} {% if view_revenue %}
<td class="text-nowrap text-end">
{{ userStat.rateBillable|money(currency) }}
</td>
<td class="text-nowrap text-end"> <td class="text-nowrap text-end">
{{ userStat.rate|money(currency) }} {{ userStat.rate|money(currency) }}
</td> </td>
@@ -461,14 +514,18 @@
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
<tr> </tbody>
<tfoot>
<tr class="summary">
<td></td> <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 %} {% 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 %} {% endif %}
<td></td> <td></td>
</tr> </tr>
</tfoot>
</table> </table>
</div> </div>
<div class="col-xs-12 col-sm-3 col-md-4 col-lg-6"> <div class="col-xs-12 col-sm-3 col-md-4 col-lg-6">

View File

@@ -288,7 +288,7 @@ class ConfigurationTest extends TestCase
'allow_overbooking_budget' => true, 'allow_overbooking_budget' => true,
'lockdown_period_timezone' => null, 'lockdown_period_timezone' => null,
'break_warning_duration' => 0, 'break_warning_duration' => 0,
'long_running_duration' => 480, 'long_running_duration' => 600,
'require_activity' => true, 'require_activity' => true,
], ],
'duration_increment' => 15, 'duration_increment' => 15,

View File

@@ -53,6 +53,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
'entry.total_plain', 'entry.total_plain',
'entry.currency', 'entry.currency',
'entry.duration', 'entry.duration',
'entry.duration_format',
'entry.duration_decimal', 'entry.duration_decimal',
'entry.duration_minutes', 'entry.duration_minutes',
'entry.begin', 'entry.begin',

View File

@@ -260,6 +260,7 @@ class DebugRendererTest extends TestCase
'entry.total_plain', 'entry.total_plain',
'entry.currency', 'entry.currency',
'entry.duration', 'entry.duration',
'entry.duration_format',
'entry.duration_decimal', 'entry.duration_decimal',
'entry.duration_minutes', 'entry.duration_minutes',
'entry.begin', 'entry.begin',

View File

@@ -11,36 +11,51 @@ namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Month; use App\Model\Statistic\Month;
use App\Model\Statistic\Year; use App\Model\Statistic\Year;
use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Model\Statistic\Year * @covers \App\Model\Statistic\Year
*/ */
class YearTest extends AbstractTimesheetTest class YearTest extends TestCase
{ {
public function testDefaultValues() public function testDefaultValues(): void
{ {
$sut = new Year('1999'); $sut = new Year('1999');
$this->assertDefaultValues($sut);
self::assertNull($sut->getMonth(1)); self::assertNull($sut->getMonth(1));
self::assertEmpty($sut->getMonths()); self::assertEmpty($sut->getMonths());
self::assertIsArray($sut->getMonths()); self::assertIsArray($sut->getMonths());
self::assertEquals('1999', $sut->getYear()); 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.0, $sut->getBillableRate());
self::assertSame(0, $sut->getDuration());
self::assertSame(0, $sut->getBillableDuration());
} }
public function testSetter() public function testSetter(): void
{ {
$sut = new Year('1999'); $sut = new Year('1999');
$this->assertSetter($sut);
$sut->setMonth(new Month('01')); $january = new Month('01');
$sut->setMonth(new Month('02')); $january->setBillableDuration(1234);
$sut->setMonth(new Month('03')); $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())); self::assertEquals(3, \count($sut->getMonths()));
$sut->setMonth(new Month('01')); $sut->setMonth(new Month('02'));
self::assertEquals(3, \count($sut->getMonths())); self::assertEquals(3, \count($sut->getMonths()));
@@ -49,9 +64,9 @@ class YearTest extends AbstractTimesheetTest
self::assertInstanceOf(Month::class, $sut->getMonth(3)); self::assertInstanceOf(Month::class, $sut->getMonth(3));
self::assertNull($sut->getMonth(4)); self::assertNull($sut->getMonth(4));
$sut->setBillableDuration(123456); self::assertSame(1235, $sut->getBillableDuration());
$sut->setBillableRate(123.456789); self::assertSame(2345, $sut->getDuration());
self::assertSame(123456, $sut->getBillableDuration()); self::assertSame(4322.22, $sut->getBillableRate());
self::assertSame(123.456789, $sut->getBillableRate()); self::assertSame(5555.55, $sut->getRate());
} }
} }

View File

@@ -7917,16 +7917,6 @@ parameters:
count: 1 count: 1
path: Model/Statistic/TimesheetTest.php 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\\.$#" message: "#^Method App\\\\Tests\\\\Model\\\\TimesheetCountedStatisticTest\\:\\:testDefaultValues\\(\\) has no return type specified\\.$#"
count: 1 count: 1

View File

@@ -5,7 +5,7 @@
<!-- Reports --> <!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned"> <trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source> <source>report_tasks_assigned</source>
<target>Meine Aufgaben</target> <target>Aufgaben pro Benutzer</target>
</trans-unit> </trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams"> <trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source> <source>report_tasks_teams</source>

View File

@@ -5,7 +5,7 @@
<!-- Reports --> <!-- Reports -->
<trans-unit id="tLV1hoP" resname="report_tasks_assigned"> <trans-unit id="tLV1hoP" resname="report_tasks_assigned">
<source>report_tasks_assigned</source> <source>report_tasks_assigned</source>
<target>My tasks</target> <target>Tasks per user</target>
</trans-unit> </trans-unit>
<trans-unit id="8c0YWZm" resname="report_tasks_teams"> <trans-unit id="8c0YWZm" resname="report_tasks_teams">
<source>report_tasks_teams</source> <source>report_tasks_teams</source>