Compare commits
3 Commits
dev
...
customer-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cc5f2587 | ||
|
|
6b672a23b4 | ||
|
|
900254b83a |
40
phpstan.neon
40
phpstan.neon
@@ -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
|
||||
|
||||
53
src/Controller/Reporting/CustomerViewController.php
Normal file
53
src/Controller/Reporting/CustomerViewController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
57
src/Event/CustomerBudgetStatisticEvent.php
Normal file
57
src/Event/CustomerBudgetStatisticEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
45
src/Reporting/CustomerView/CustomerViewForm.php
Normal file
45
src/Reporting/CustomerView/CustomerViewForm.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
src/Reporting/CustomerView/CustomerViewModel.php
Normal file
136
src/Reporting/CustomerView/CustomerViewModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/Reporting/CustomerView/CustomerViewQuery.php
Normal file
55
src/Reporting/CustomerView/CustomerViewQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
128
templates/reporting/customer_list_data.html.twig
Normal file
128
templates/reporting/customer_list_data.html.twig
Normal 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 %}
|
||||
–
|
||||
{% 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 %}
|
||||
16
templates/reporting/customer_view.html.twig
Normal file
16
templates/reporting/customer_view.html.twig
Normal 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 %}
|
||||
@@ -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') %}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user