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 = {
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

View File

@@ -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 {

View File

@@ -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
View File

@@ -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",

View File

@@ -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
-

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": {
"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",

View File

@@ -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",

View File

@@ -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
*/

View File

@@ -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()

View File

@@ -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),

View File

@@ -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
{

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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">

View File

@@ -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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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());
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>