Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Papst
b1cc5f2587 fix test 2023-10-31 16:58:47 +01:00
Kevin Papst
6b672a23b4 new "customer overview" listing report 2023-10-31 16:32:11 +01:00
Kevin Papst
900254b83a change default visibility 2023-10-31 16:30:01 +01:00
14 changed files with 686 additions and 47 deletions

View File

@@ -1331,46 +1331,6 @@ parameters:
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'billable' on mixed\\.$#"
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 3
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 4
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'exported' on mixed\\.$#"
count: 2
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'id' on mixed\\.$#"
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'internalRate' on mixed\\.$#"
count: 3
path: src/Customer/CustomerStatisticService.php
-
message: "#^Cannot access offset 'rate' on mixed\\.$#"
count: 4
path: src/Customer/CustomerStatisticService.php
-
message: "#^Method App\\\\Customer\\\\CustomerStatisticService\\:\\:createStatisticQueryBuilder\\(\\) has parameter \\$customers with no value type specified in iterable type array\\.$#"
count: 1
path: src/Customer/CustomerStatisticService.php
-
message: "#^Method App\\\\Customer\\\\CustomerStatisticService\\:\\:getBudgetStatistic\\(\\) should return array\\<int, App\\\\Model\\\\CustomerStatistic\\> but returns array\\<int\\|string, App\\\\Model\\\\CustomerStatistic\\>\\.$#"
count: 1

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Customer\CustomerStatisticService;
use App\Reporting\CustomerView\CustomerViewForm;
use App\Reporting\CustomerView\CustomerViewQuery;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class CustomerViewController extends AbstractController
{
#[Route(path: '/reporting/customer_view', name: 'report_customer_view', methods: ['GET', 'POST'])]
#[IsGranted('report:customer')]
#[IsGranted(new Expression("is_granted('budget_any', 'customer')"))]
public function __invoke(Request $request, CustomerStatisticService $service): Response
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new CustomerViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createFormForGetRequest(CustomerViewForm::class, $query);
$form->submit($request->query->all(), false);
$customers = $service->findCustomersForView($query);
$entries = $service->getCustomerView($user, $customers, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getCustomer();
$byCustomer[$customer->getId()] = $entry;
}
return $this->render('reporting/customer_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'report_title' => 'report_customer_view',
'tableName' => 'customer_view_reporting',
'now' => $dateFactory->createDateTime(),
]);
}
}

View File

@@ -11,23 +11,33 @@ namespace App\Customer;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Event\CustomerBudgetStatisticEvent;
use App\Event\CustomerStatisticEvent;
use App\Model\CustomerBudgetStatisticModel;
use App\Model\CustomerStatistic;
use App\Reporting\CustomerView\CustomerViewModel;
use App\Reporting\CustomerView\CustomerViewQuery;
use App\Repository\CustomerRepository;
use App\Repository\Loader\CustomerLoader;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Psr\EventDispatcher\EventDispatcherInterface;
/**
* @final
*/
class CustomerStatisticService
{
public function __construct(private TimesheetRepository $timesheetRepository, private EventDispatcherInterface $dispatcher)
public function __construct(
private CustomerRepository $customerRepository,
private TimesheetRepository $timesheetRepository,
private EventDispatcherInterface $dispatcher
)
{
}
@@ -85,6 +95,7 @@ class CustomerStatisticService
$result = $qb->getQuery()->getResult();
if (null !== $result) {
/** @var array{'id': string, 'duration': int, 'internalRate': float, 'counter': int, 'rate': float, 'billable': int, 'exported': int} $resultRow */
foreach ($result as $resultRow) {
$statistic = $statistics[$resultRow['id']];
$statistic->setDuration($statistic->getDuration() + $resultRow['duration']);
@@ -113,12 +124,16 @@ class CustomerStatisticService
return $statistics;
}
/**
* @param array<Customer> $customers
*/
private function createStatisticQueryBuilder(array $customers, DateTime $begin = null, ?DateTime $end = null): QueryBuilder
{
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(p.customer) AS id')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
@@ -149,4 +164,177 @@ class CustomerStatisticService
return $qb;
}
/**
* @param CustomerViewQuery $query
* @return Customer[]
*/
public function findCustomersForView(CustomerViewQuery $query): array
{
$user = $query->getUser();
$qb = $this->customerRepository->createQueryBuilder('c');
$qb
->select('c')
->andWhere($qb->expr()->eq('c.visible', true))
->addGroupBy('c')
;
if ($query->isIncludeWithBudget()) {
$qb->andWhere(
$qb->expr()->orX(
$qb->expr()->gt('c.timeBudget', 0),
$qb->expr()->gt('c.budget', 0)
)
);
} elseif ($query->isIncludeWithoutBudget()) {
$qb->andWhere(
$qb->expr()->andX(
$qb->expr()->eq('c.timeBudget', 0),
$qb->expr()->eq('c.budget', 0)
)
);
}
$this->customerRepository->addPermissionCriteria($qb, $user);
/** @var Customer[] $customers */
$customers = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new CustomerLoader($this->customerRepository->createQueryBuilder('c')->getEntityManager(), false);
$loader->loadResults($customers);
return $customers;
}
/**
* @param User $user
* @param Customer[] $customers
* @param DateTime $today
* @return CustomerViewModel[]
*/
public function getCustomerView(User $user, array $customers, DateTime $today): array
{
$today = clone $today;
/** @var array<int, CustomerViewModel> $customerViews */
$customerViews = [];
foreach ($customers as $customer) {
$customerViews[$customer->getId()] = new CustomerViewModel($customer);
}
$budgetStats = $this->getBudgetStatisticModelForCustomers($customers, $today);
foreach ($budgetStats as $model) {
$customerViews[$model->getCustomer()->getId()]->setBudgetStatisticModel($model);
}
$customerIds = array_keys($customerViews);
$tplQb = $this->timesheetRepository->createQueryBuilder('t');
$tplQb
->select('c.id AS id')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->join(Customer::class, 'c', Query\Expr\Join::WITH, 'p.customer = c.id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) AS duration')
->addSelect('COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($tplQb->expr()->in('c.id', ':customer'))
->groupBy('id')
->setParameter('customer', array_values($customerIds))
;
$qb = clone $tplQb;
$result = $qb->getQuery()->getScalarResult();
/** @var array{'duration': int, 'amount': int, 'rate': float, 'id': string, 'exported': int} $row */
foreach ($result as $row) {
$customerViews[$row['id']]->setDurationTotal($row['duration']);
$customerViews[$row['id']]->setRateTotal($row['rate']);
$customerViews[$row['id']]->setTimesheetCounter($row['amount']);
}
$qb = clone $tplQb;
$qb
->addSelect('t.exported')
->addSelect('t.billable')
->addGroupBy('t.exported')
->addGroupBy('t.billable')
;
$result = $qb->getQuery()->getScalarResult();
/** @var array{'duration': int, 'billable': int, 'rate': float, 'exported': int, 'id': string} $row */
foreach ($result as $row) {
$view = $customerViews[$row['id']];
if ($row['billable'] === 1 && $row['exported'] === 1) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
} elseif ($row['billable'] === 1 && $row['exported'] === 0) {
$view->setBillableDuration($view->getBillableDuration() + $row['duration']);
$view->setBillableRate($view->getBillableRate() + $row['rate']);
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
$view->setNotBilledDuration($view->getNotBilledDuration() + $row['duration']);
$view->setNotBilledRate($view->getNotBilledRate() + $row['rate']);
} elseif ($row['billable'] === 0 && $row['exported'] === 0) {
$view->setNotExportedDuration($view->getNotExportedDuration() + $row['duration']);
$view->setNotExportedRate($view->getNotExportedRate() + $row['rate']);
}
// the last possible case $row['billable'] === 0 && $row['exported'] === 1 is extremely unlikely and not used
}
return array_values($customerViews);
}
/**
* @param Customer[] $customers
* @param DateTime $today
* @return CustomerBudgetStatisticModel[]
*/
public function getBudgetStatisticModelForCustomers(array $customers, DateTime $today): array
{
$models = [];
$monthly = [];
$allTime = [];
foreach ($customers as $customer) {
$models[$customer->getId()] = new CustomerBudgetStatisticModel($customer);
if ($customer->isMonthlyBudget()) {
$monthly[] = $customer;
} else {
$allTime[] = $customer;
}
}
$statisticsTotal = $this->getBudgetStatistic($customers);
foreach ($statisticsTotal as $id => $statistic) {
$models[$id]->setStatisticTotal($statistic);
}
$dateFactory = new DateTimeFactory($today->getTimezone());
$begin = null;
$end = $today;
if (\count($monthly) > 0) {
$begin = $dateFactory->getStartOfMonth($today);
$end = $dateFactory->getEndOfMonth($today);
$statistics = $this->getBudgetStatistic($monthly, $begin, $end);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
if (\count($allTime) > 0) {
// display the budget at the end of the selected period and not the total sum of all times (do not include times in the future)
$statistics = $this->getBudgetStatistic($allTime, null, $today);
foreach ($statistics as $id => $statistic) {
$models[$id]->setStatistic($statistic);
}
}
$event = new CustomerBudgetStatisticEvent($models, $begin, $end);
$this->dispatcher->dispatch($event);
return $models;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Model\CustomerBudgetStatisticModel;
final class CustomerBudgetStatisticEvent
{
/**
* @param CustomerBudgetStatisticModel[] $models
* @param \DateTime|null $begin
* @param \DateTime|null $end
*/
public function __construct(private array $models, private ?\DateTime $begin = null, private ?\DateTime $end = null)
{
}
public function getModel(int $customerId): ?CustomerBudgetStatisticModel
{
if (isset($this->models[$customerId])) {
return $this->models[$customerId];
}
foreach ($this->models as $model) {
if ($model->getCustomer()->getId() === $customerId) {
return $model;
}
}
return null;
}
/**
* @return CustomerBudgetStatisticModel[]
*/
public function getModels(): array
{
return $this->models;
}
public function getBegin(): ?\DateTime
{
return $this->begin;
}
public function getEnd(): ?\DateTime
{
return $this->end;
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting\CustomerView;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<CustomerViewQuery>
*/
final class CustomerViewForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('budgetType', ChoiceType::class, [
'label' => false,
'required' => false,
'placeholder' => null,
'expanded' => true,
'choices' => [
'all' => null,
'includeWithBudget' => true,
'includeNoBudget' => false
]
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => CustomerViewQuery::class,
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting\CustomerView;
use App\Entity\Customer;
use App\Model\BudgetStatisticModelInterface;
final class CustomerViewModel
{
private int $timesheetCounter = 0;
private int $durationTotal = 0;
private float $rateTotal = 0.00;
private int $notExportedDuration = 0;
private float $notExportedRate = 0.00;
private int $notBilledDuration = 0;
private float $notBilledRate = 0.00;
private int $billableDuration = 0;
private float $billableRate = 0.00;
private ?BudgetStatisticModelInterface $budgetStatisticModel = null;
public function __construct(private Customer $customer)
{
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getTimesheetCounter(): int
{
return $this->timesheetCounter;
}
public function setTimesheetCounter(int $timesheetCounter): void
{
$this->timesheetCounter = $timesheetCounter;
}
public function getDurationTotal(): int
{
return $this->durationTotal;
}
public function setDurationTotal(int $durationTotal): void
{
$this->durationTotal = $durationTotal;
}
public function getNotExportedDuration(): int
{
return $this->notExportedDuration;
}
public function setNotExportedDuration(int $notExportedDuration): void
{
$this->notExportedDuration = $notExportedDuration;
}
public function getNotExportedRate(): float
{
return $this->notExportedRate;
}
public function setNotExportedRate(float $notExportedRate): void
{
$this->notExportedRate = $notExportedRate;
}
public function getNotBilledDuration(): int
{
return $this->notBilledDuration;
}
public function setNotBilledDuration(int $notBilledDuration): void
{
$this->notBilledDuration = $notBilledDuration;
}
public function getNotBilledRate(): float
{
return $this->notBilledRate;
}
public function setNotBilledRate(float $notBilledRate): void
{
$this->notBilledRate = $notBilledRate;
}
public function getBillableDuration(): int
{
return $this->billableDuration;
}
public function setBillableDuration(int $billableDuration): void
{
$this->billableDuration = $billableDuration;
}
public function getBillableRate(): float
{
return $this->billableRate;
}
public function setBillableRate(float $billableRate): void
{
$this->billableRate = $billableRate;
}
public function getRateTotal(): float
{
return $this->rateTotal;
}
public function setRateTotal(float $rateTotal): void
{
$this->rateTotal = $rateTotal;
}
public function getBudgetStatisticModel(): BudgetStatisticModelInterface
{
return $this->budgetStatisticModel; // @phpstan-ignore-line
}
public function setBudgetStatisticModel(BudgetStatisticModelInterface $budgetStatisticModel): void
{
$this->budgetStatisticModel = $budgetStatisticModel;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Reporting\CustomerView;
use App\Entity\User;
use DateTime;
final class CustomerViewQuery
{
private ?bool $budgetType = true;
public function __construct(private DateTime $today, private User $user)
{
}
public function getUser(): ?User
{
return $this->user;
}
public function getBudgetType(): ?bool
{
return $this->budgetType;
}
/**
* @internal
*/
public function setBudgetType(?bool $budgetType): void
{
$this->budgetType = $budgetType;
}
public function isIncludeWithoutBudget(): bool
{
return $this->budgetType === false;
}
public function isIncludeWithBudget(): bool
{
return $this->budgetType === true;
}
public function getToday(): DateTime
{
return $this->today;
}
}

View File

@@ -54,6 +54,7 @@ final class ReportingService
if ($this->security->isGranted('report:customer')) {
if ($viewOther) {
$event->addReport(new Report('customer_view', 'report_customer_view', 'report_customer_view', 'customer'));
$event->addReport(new Report('report_customer_monthly_projects', 'report_customer_monthly_projects', 'report_customer_monthly_projects', 'customer'));
}
}

View File

@@ -75,7 +75,7 @@ class CustomerRepository extends EntityRepository
return $this->count([]);
}
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
{
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
if ($permissions->count() > 0) {

View File

@@ -0,0 +1,128 @@
{% extends 'reporting/layout.html.twig' %}
{% import "macros/datatables.html.twig" as tables %}
{% set showMoneyBudget = is_granted('budget_money', 'customer') %}
{% set showTimeBudget = is_granted('budget_time', 'customer') %}
{% set availableColumns = {
'name': {'class': 'alwaysVisible'},
} %}
{% if showTimeBudget %}
{% set availableColumns = availableColumns|merge({
'timeBudget': {'class': 'd-none d-md-table-cell', 'title': 'timeBudget'|trans},
}) %}
{% endif %}
{% if showMoneyBudget %}
{% set availableColumns = availableColumns|merge({
'budget': {'class': 'd-none d-md-table-cell', 'title': 'budget'|trans},
}) %}
{% endif %}
{% if showTimeBudget %}
{% set availableColumns = availableColumns|merge({
'durationTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.durationTotal'|trans, 'columnClass': 'w-min'},
}) %}
{% endif %}
{% if showTimeBudget %}
{% set availableColumns = availableColumns|merge({
'rateTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.amountTotal'|trans, 'columnClass': 'w-min'},
}) %}
{% endif %}
{% if showTimeBudget and is_granted('create_export') %}
{% set availableColumns = availableColumns|merge({
'exported': {'class': 'd-none d-xl-table-cell text-end hw-min w-min', 'title': 'not_exported'|trans, 'columnClass': 'w-min'},
}) %}
{% endif %}
{% if showMoneyBudget and is_granted('view_invoice') %}
{% set availableColumns = availableColumns|merge({
'invoiced': {'class': 'd-none d-xl-table-cell text-end hw-min w-min', 'title': 'not_invoiced'|trans, 'columnClass': 'w-min'},
}) %}
{% endif %}
{% set availableColumns = availableColumns|merge({
'comment': {'class': 'd-none', 'title': 'comment'|trans},
'actions': {'class': 'actions alwaysVisible'},
}) %}
{% set tableName = tableName|default('customer_view_reporting') %}
{% set skipColumns = skipColumns is defined ? skipColumns : {} %}
{% set columns = {} %}
{% for name, config in availableColumns %}
{% if name not in skipColumns %}
{% set columns = columns|merge({(name): config}) %}
{% endif %}
{% endfor %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block report %}
{% set hasData = entries|length > 0 %}
{% embed '@theme/embeds/card.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "customer/actions.html.twig" as customerActions %}
{% block box_body_class %}{{ tableName }}-box {% if hasData %}p-0{% endif %}{% endblock %}
{% block box_body %}
{% if not hasData %}
{{ widgets.nothing_found() }}
{% else %}
{{ tables.datatable_header(tableName, columns, null, {boxClass: ''}) }}
{% for id, entry in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
{% set customer = entry.customer %}
{% set budgetStats = entry.getBudgetStatisticModel() %}
{% set currency = customer.currency %}
<tr {{ widgets.customer_row_attr(customer, now) }}>
{% for name, column_config in columns %}
<td class="{{ tables.data_table_column_class(tableName, columns, name) }}">
{% if name == 'name' %}
{{ widgets.label_customer(customer) }}
{% elseif name == 'lastRecord' %}
{% if entry.lastRecord is not null %}
{{ entry.lastRecord|date_short }}
{% else %}
&ndash;
{% endif %}
{% elseif name == 'today' %}
{{ entry.durationDay|duration }}
{% elseif name == 'week' %}
{{ entry.durationWeek|duration }}
{% elseif name == 'month' %}
{{ entry.durationMonth|duration }}
{% elseif name == 'durationTotal' %}
{{ entry.durationTotal|duration }}
{% elseif name == 'rateTotal' %}
{{ entry.budgetStatisticModel.rateBillableTotal|money(currency) }}
{% elseif name == 'timeBudget' %}
{% if budgetStats.hasTimeBudget() and is_granted('time', customer) %}
{{ progress.progressbar_timebudget(budgetStats) }}
{% endif %}
{% elseif name == 'budget' %}
{% if customer.hasBudget() and is_granted('budget', customer) %}
{{ progress.progressbar_budget(budgetStats, customer.currency) }}
{% endif %}
{% elseif name == 'exported' %}
<a href="{{ path('export', {'customers[]': customer.id, 'daterange': '', 'preview': true}) }}">
{{ entry.notExportedDuration|duration }}
</a>
{% elseif name == 'invoiced' %}
<a href="{{ path('invoice', {'customers[]': customer.id, 'daterange': ''}) }}">
{{ entry.notBilledRate|money(currency) }}
</a>
{% elseif name == 'comment' %}
{{ customer.comment }}
{% elseif name == 'actions' %}
{{ customerActions.customer(customer, 'custom') }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends 'reporting/customer_list_data.html.twig' %}
{% block report_form_layout %}
<div class="dropdown">
<button type="button" class="btn dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ icon('filter', true) }}
</button>
<ul class="dropdown-menu checkbox-menu">
{% for option in form.budgetType.children %}
<li class="dropdown-item">
{{ form_widget(option) }}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -18,10 +18,10 @@
}) %}
{% endif %}
{% set availableColumns = availableColumns|merge({
'lastRecord': {'class': 'd-none d-sm-table-cell text-start hw-min w-min', 'title': 'last_record'|trans, 'columnClass': 'w-min'},
'lastRecord': {'class': 'd-none text-start hw-min w-min', 'title': 'last_record'|trans, 'columnClass': 'w-min'},
'today': {'class': 'd-none text-end hw-min w-min', 'title': 'stats.durationToday'|trans},
'week': {'class': 'd-none text-end hw-min w-min', 'title': 'stats.durationWeek'|trans},
'month': {'class': 'd-none d-lg-table-cell text-end hw-min w-min', 'title': 'stats.durationMonth'|trans},
'month': {'class': 'd-none text-end hw-min w-min', 'title': 'stats.durationMonth'|trans},
'durationTotal': {'class': 'text-end hw-min w-min', 'title': 'stats.durationTotal'|trans, 'columnClass': 'w-min'},
}) %}
{% if showTimeBudget and is_granted('create_export') %}

View File

@@ -26,7 +26,7 @@ class ReportingControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/reporting/');
$nodes = $client->getCrawler()->filter('section.content div.row-cards a.card-link');
$this->assertCount(11, $nodes);
$this->assertCount(12, $nodes);
}
public function testOverviewPageAsUser()

View File

@@ -49,6 +49,6 @@ class ReportingServiceTest extends TestCase
$sut = $this->getSut(true);
$reports = $sut->getAvailableReports(new User());
self::assertIsArray($reports);
self::assertCount(11, $reports);
self::assertCount(12, $reports);
}
}