billable timesheets, inactive projects report, bookmark export search (#2503)

This commit is contained in:
Kevin Papst
2021-04-18 21:51:27 +02:00
committed by GitHub
parent 8664d94ea6
commit 0330f45c6a
97 changed files with 1516 additions and 664 deletions

View File

@@ -304,7 +304,9 @@ final class KimaiImporterCommand extends Command
$validationMessages = [];
try {
$usedEmails = [];
$userIds = [];
foreach ($users as $oldUser) {
$userIds[] = $oldUser['userID'];
if (empty($oldUser['mail'])) {
$validationMessages[] = sprintf('User "%s" with ID %s has no email', $oldUser['name'], $oldUser['userID']);
continue;
@@ -325,6 +327,15 @@ final class KimaiImporterCommand extends Command
$validationMessages[] = sprintf('Project "%s" with ID %s has unknown customer with ID %s', $oldProject['name'], $oldProject['projectID'], $oldProject['customerID']);
}
}
foreach ($rates as $oldRate) {
if ($oldRate['userID'] === null) {
continue;
}
if (!\in_array($oldRate['userID'], $userIds)) {
$validationMessages[] = sprintf('Unknown user with ID "%s" found for rate with project "%s" and activity "%s"', $oldRate['userID'], $oldRate['projectID'], $oldRate['activityID']);
}
}
} catch (Exception $ex) {
$validationMessages[] = $ex->getMessage();
}

View File

@@ -196,7 +196,9 @@ abstract class AbstractController extends BaseAbstractController implements Serv
// apply bookmark ONLY if search form was not submitted manually
if ($bookmark !== null && !$request->query->has('performSearch')) {
$data->setBookmark($bookmark);
$submitData = array_merge($bookmark->getContent(), $submitData);
if (!$request->query->has('setDefaultQuery')) {
$submitData = array_merge($bookmark->getContent(), $submitData);
}
}
// clean up parameters from unknown search values

View File

@@ -311,7 +311,7 @@ final class ActivityController extends AbstractController
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
'data-form-event' => 'kimai.activityUpdate kimai.activityDelete',
'data-form-event' => 'kimai.activityDelete',
'data-msg-success' => 'action.delete.success',
'data-msg-error' => 'action.delete.error',
]

View File

@@ -376,7 +376,7 @@ final class CustomerController extends AbstractController
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
'data-form-event' => 'kimai.customerUpdate kimai.customerDelete',
'data-form-event' => 'kimai.customerDelete',
'data-msg-success' => 'action.delete.success',
'data-msg-error' => 'action.delete.error',
]

View File

@@ -15,7 +15,6 @@ use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\Query\ExportQuery;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -50,26 +49,40 @@ class ExportController extends AbstractController
$entries = [];
$form = $this->getToolbarForm($query, 'GET');
$form->setData($query);
$form->submit($request->query->all(), false);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('export');
}
if ($form->isValid()) {
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$query->setPageSize($maxItemsPreview);
$entries = $this->getEntries($query);
$byCustomer = [];
if ($form->isValid() && ($query->hasBookmark() || $request->query->has('performSearch'))) {
$showPreview = true;
$entries = $this->getEntries($query);
foreach ($entries as $entry) {
$cid = $entry->getProject()->getCustomer()->getId();
if (!isset($byCustomer[$cid])) {
$byCustomer[$cid] = [
'customer' => $entry->getProject()->getCustomer(),
'rate' => 0,
'internalRate' => 0,
'duration' => 0,
];
}
$byCustomer[$cid]['rate'] += $entry->getRate();
$byCustomer[$cid]['internalRate'] += $entry->getInternalRate();
$byCustomer[$cid]['duration'] += $entry->getDuration();
}
}
return $this->render('export/index.html.twig', [
'by_customer' => $byCustomer,
'query' => $query,
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
'preview_max' => $maxItemsPreview,
'preview_limit' => $maxItemsPreview,
'preview_show' => $showPreview,
'decimal' => $this->getUser()->isExportDecimal(),
]);
}

View File

@@ -420,7 +420,7 @@ final class ProjectController extends AbstractController
$deleteForm = $this->createFormBuilder(null, [
'attr' => [
'data-form-event' => 'kimai.projectUpdate kimai.projectDelete',
'data-form-event' => 'kimai.projectDelete',
'data-msg-success' => 'action.delete.success',
'data-msg-error' => 'action.delete.error',
]

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\Controller\Reporting;
use App\Controller\AbstractController;
use App\Reporting\ProjectInactive\ProjectInactiveForm;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectStatisticService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
final class InactiveProjectController extends AbstractController
{
/**
* @Route(path="/reporting/project_inactive", name="report_project_inactive", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_project')")
*/
public function __invoke(Request $request, ProjectStatisticService $service)
{
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectInactiveQuery($dateFactory->createDateTime('-1 year'), $user);
$form = $this->createForm(ProjectInactiveForm::class, $query, [
'timezone' => $user->getTimezone()
]);
$form->submit($request->query->all(), false);
$projects = $service->findInactiveProjects($query);
$entries = $service->getProjectView($user, $projects, $query->getLastChange());
$byCustomer = [];
foreach ($entries as $entry) {
$customer = $entry->getProject()->getCustomer();
if (!isset($byCustomer[$customer->getId()])) {
$byCustomer[$customer->getId()] = ['customer' => $customer, 'projects' => []];
}
$byCustomer[$customer->getId()]['projects'][] = $entry;
}
return $this->render('reporting/project_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'title' => 'report_inactive_project',
'tableName' => 'inactive_project_reporting',
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
}

View File

@@ -10,9 +10,9 @@
namespace App\Controller\Reporting;
use App\Controller\AbstractController;
use App\Reporting\ProjectStatisticService;
use App\Reporting\ProjectView\ProjectViewForm;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Reporting\ProjectView\ProjectViewService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
@@ -23,17 +23,17 @@ final class ProjectViewController extends AbstractController
* @Route(path="/reporting/project_view", name="report_project_view", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_project')")
*/
public function __invoke(Request $request, ProjectViewService $service)
public function __invoke(Request $request, ProjectStatisticService $service)
{
$query = new ProjectViewQuery($this->getDateTimeFactory()->createDateTime(), $this->getUser());
$form = $this->createForm(ProjectViewForm::class, $query, [
'action' => $this->generateUrl('report_project_view')
]);
$dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$query = new ProjectViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createForm(ProjectViewForm::class, $query);
$form->submit($request->query->all(), false);
$entries = $service->getProjectView($query);
$projects = $service->findProjectsForView($query);
$entries = $service->getProjectView($user, $projects, $query->getToday());
$byCustomer = [];
foreach ($entries as $entry) {
@@ -47,6 +47,9 @@ final class ProjectViewController extends AbstractController
return $this->render('reporting/project_view.html.twig', [
'entries' => $byCustomer,
'form' => $form->createView(),
'title' => 'report_project_view',
'tableName' => 'project_view_reporting',
'now' => $this->getDateTimeFactory()->createDateTime(),
]);
}
}

View File

@@ -40,7 +40,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
public const MAX_DESCRIPTION_LENGTH = 500;
public const MAX_DESCRIPTION_LENGTH = 200;
public const ADD_TAGS_MAX_ENTRIES = 10000;
public const MAX_TAG_PER_ENTRY = 3;

View File

@@ -241,7 +241,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Entity"})
* @Serializer\Groups({"Default"})
*
* @ORM\Column(name="exported", type="boolean", nullable=false)
* @Assert\NotNull()
@@ -250,6 +250,9 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
/**
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @ORM\Column(name="billable", type="boolean", nullable=false, options={"default": true})
* @Assert\NotNull()
*/

View File

@@ -380,6 +380,11 @@ class User extends BaseUser implements UserInterface
return $this->getPreferenceValue('theme.layout', 'fixed') === 'boxed';
}
public function isExportDecimal(): bool
{
return (bool) $this->getPreferenceValue('timesheet.export_decimal', false);
}
public function setTimezone(?string $timezone)
{
if ($timezone === null) {

View File

@@ -47,7 +47,17 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
}
if ($event->isIndexView() && $this->isGranted('delete', $timesheet)) {
$event->addAction('trash', ['url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', 'attr' => ['data-event' => 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method' => 'DELETE', 'data-question' => 'confirm.delete', 'data-msg-error' => 'action.delete.error', 'data-msg-success' => 'action.delete.success']]);
$event->addAction('trash', [
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
'class' => 'api-link',
'attr' => [
'data-event' => 'kimai.timesheetDelete',
'data-method' => 'DELETE',
'data-question' => 'confirm.delete',
'data-msg-error' => 'action.delete.error',
'data-msg-success' => 'action.delete.success'
]
]);
}
}

View File

@@ -29,7 +29,8 @@ class ActivitySubscriber extends AbstractActionsSubscriber
if ($activity->getId() === null) {
return;
}
if ($this->isGranted('view', $activity)) {
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
$event->addAction('details', ['url' => $this->path('activity_details', ['id' => $activity->getId()])]);
}
@@ -68,7 +69,7 @@ class ActivitySubscriber extends AbstractActionsSubscriber
$event->addAction('create-timesheet', ['icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
}
if ($event->isIndexView() && $this->isGranted('delete', $activity)) {
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {
$event->addDelete($this->path('admin_activity_delete', ['id' => $activity->getId()]));
}
}

View File

@@ -30,7 +30,7 @@ class CustomerSubscriber extends AbstractActionsSubscriber
return;
}
if ($this->isGranted('view', $customer)) {
if (!$event->isView('customer_details') && $this->isGranted('view', $customer)) {
$event->addAction('details', ['url' => $this->path('customer_details', ['id' => $customer->getId()])]);
}
@@ -69,7 +69,7 @@ class CustomerSubscriber extends AbstractActionsSubscriber
}
if ($event->isIndexView() && $this->isGranted('delete', $customer)) {
$event->addAction('trash', ['url' => $this->path('admin_customer_delete', ['id' => $customer->getId()]), 'class' => 'modal-ajax-form text-red']);
$event->addDelete($this->path('admin_customer_delete', ['id' => $customer->getId()]));
}
if ($this->isGranted('view_reporting') && $this->isGranted('budget_project')) {

View File

@@ -30,7 +30,7 @@ class ProjectSubscriber extends AbstractActionsSubscriber
return;
}
if ($this->isGranted('view', $project)) {
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
$event->addAction('details', ['url' => $this->path('project_details', ['id' => $project->getId()])]);
}
@@ -68,8 +68,8 @@ class ProjectSubscriber extends AbstractActionsSubscriber
$event->addAction('copy', ['url' => $this->path('admin_project_duplicate', ['id' => $project->getId()])]);
}
if ($event->isIndexView() && $this->isGranted('delete', $project)) {
$event->addAction('trash', ['url' => $this->path('admin_project_delete', ['id' => $project->getId()]), 'class' => 'modal-ajax-form text-red']);
if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {
$event->addDelete($this->path('admin_project_delete', ['id' => $project->getId()]));
}
}
}

View File

@@ -70,7 +70,7 @@ class UserSubscriber extends AbstractActionsSubscriber
}
if ($event->isIndexView() && $this->isGranted('delete', $user)) {
$event->addAction('trash', ['url' => $this->path('admin_user_delete', ['id' => $user->getId()]), 'class' => 'modal-ajax-form']);
$event->addDelete($this->path('admin_user_delete', ['id' => $user->getId()]));
}
}
}

View File

@@ -82,6 +82,7 @@ abstract class AbstractSpreadsheetRenderer
'wrapText' => false,
],
'exported' => [],
'billable' => [],
'tags' => [],
'hourlyRate' => [],
'fixedRate' => [],
@@ -122,7 +123,16 @@ abstract class AbstractSpreadsheetRenderer
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
@@ -134,7 +144,15 @@ abstract class AbstractSpreadsheetRenderer
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
@@ -146,7 +164,16 @@ abstract class AbstractSpreadsheetRenderer
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$excelDate = Date::PHPToExcel($date);
if ($excelDate === false) {
$sheet->setCellValueByColumnAndRow($column, $row, $date);
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
// TODO why is that format hardcoded and does not depend on the users locale?
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
@@ -338,7 +365,14 @@ abstract class AbstractSpreadsheetRenderer
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$exported = $entity->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$exported = $entity->isExported() ? 'yes' : 'no';
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
};
}
if (isset($columns['billable']) && !isset($columns['billable']['render'])) {
$columns['billable']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
$exported = (method_exists($entity, 'isBillable') && !$entity->isBillable()) ? 'no' : 'yes';
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
};
}

View File

@@ -71,9 +71,9 @@ class HtmlRenderer
{
$decimal = false;
if (null !== $query->getCurrentUser()) {
$decimal = (bool) $query->getCurrentUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
$decimal = $query->getCurrentUser()->isExportDecimal();
} elseif (null !== $query->getUser()) {
$decimal = (bool) $query->getUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
$decimal = $query->getUser()->isExportDecimal();
}
return ['decimal' => $decimal];

View File

@@ -64,9 +64,9 @@ class PDFRenderer
{
$decimal = false;
if (null !== $query->getCurrentUser()) {
$decimal = (bool) $query->getCurrentUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
$decimal = $query->getCurrentUser()->isExportDecimal();
} elseif (null !== $query->getUser()) {
$decimal = (bool) $query->getUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
$decimal = $query->getUser()->isExportDecimal();
}
return ['decimal' => $decimal];

View File

@@ -21,6 +21,9 @@ interface ExportItemInterface extends InvoiceItemInterface
*/
public function isExported(): bool;
// will be activated with 2.0
// public function isBillable(): bool;
/**
* Returns the named meta field or null.
*

View File

@@ -10,6 +10,7 @@
namespace App\Form;
use App\Entity\Timesheet;
use App\Form\Type\BillableType;
use App\Form\Type\DateTimePickerType;
use App\Form\Type\DescriptionType;
use App\Form\Type\DurationType;
@@ -128,6 +129,7 @@ class TimesheetEditForm extends AbstractType
$builder->add('metaFields', MetaFieldsCollectionType::class);
$this->addExported($builder, $options);
$this->addBillable($builder, $options);
}
protected function showCustomer(array $options, bool $isNew, int $customerCount): bool
@@ -264,6 +266,15 @@ class TimesheetEditForm extends AbstractType
]);
}
protected function addBillable(FormBuilderInterface $builder, array $options)
{
if (!$options['include_billable']) {
return;
}
$builder->add('billable', BillableType::class, []);
}
/**
* {@inheritdoc}
*/
@@ -276,6 +287,7 @@ class TimesheetEditForm extends AbstractType
'csrf_token_id' => 'timesheet_edit',
'include_user' => false,
'include_exported' => false,
'include_billable' => true,
'include_rate' => true,
'docu_chapter' => 'timesheet.html',
'method' => 'POST',

View File

@@ -12,7 +12,6 @@ namespace App\Form\Toolbar;
use App\Repository\Query\ExportQuery;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,6 +26,7 @@ class ExportToolbarForm extends AbstractToolbarForm
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addSearchTermInputField($builder);
$this->addBillableChoice($builder);
$this->addExportStateChoice($builder);
$this->addTimesheetStateChoice($builder);
if ($options['include_user']) {
@@ -42,9 +42,6 @@ class ExportToolbarForm extends AbstractToolbarForm
'label' => 'label.mark_as_exported',
'required' => false,
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
}
/**

View File

@@ -38,6 +38,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
$this->addUsersChoice($builder);
}
$this->addTimesheetStateChoice($builder);
$this->addBillableChoice($builder);
$this->addExportStateChoice($builder);
$this->addPageSizeChoice($builder);
$this->addHiddenPagination($builder);

View File

@@ -30,9 +30,7 @@ interface InvoiceItemInterface
public function getRate(): float;
// will be activated with 2.0
/*
public function getInternalRate(): ?float;
*/
// public function getInternalRate(): ?float;
public function getUser(): ?User;

View File

@@ -17,6 +17,10 @@ class Day
* @var int
*/
protected $totalDuration = 0;
/**
* @var int|null
*/
private $totalDurationBillable = 0;
/**
* @var float
*/
@@ -54,6 +58,16 @@ class Day
return $this;
}
public function getTotalDurationBillable(): int
{
return $this->totalDurationBillable;
}
public function setTotalDurationBillable(int $seconds): void
{
$this->totalDurationBillable = $seconds;
}
public function getTotalRate(): float
{
return $this->totalRate;

View File

@@ -11,27 +11,14 @@ namespace App\Model\Statistic;
use InvalidArgumentException;
/**
* Monthly statistics
*/
class Month
final class Month
{
/**
* @var string
*/
protected $month;
/**
* @var int
*/
protected $totalDuration = 0;
/**
* @var float
*/
protected $totalRate = 0.00;
private $month;
private $totalDuration = 0;
private $totalRate = 0.00;
private $billableDuration = 0;
private $billableRate = 0.00;
/**
* @param string $month
*/
public function __construct(string $month)
{
$monthNumber = (int) $month;
@@ -43,14 +30,16 @@ class Month
$this->month = $month;
}
/**
* @return string
*/
public function getMonth()
public function getMonth(): string
{
return $this->month;
}
public function getMonthNumber(): int
{
return (int) $this->month;
}
public function getTotalDuration(): int
{
return $this->totalDuration;
@@ -74,4 +63,24 @@ class Month
return $this;
}
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;
}
}

View File

@@ -23,25 +23,19 @@ class Year
*/
protected $months = [];
/**
* @param string $year
*/
public function __construct($year)
public function __construct(string $year)
{
$this->year = $year;
}
/**
* @return string
*/
public function getYear()
public function getYear(): string
{
return $this->year;
}
public function setMonth(Month $month): Year
{
$this->months[(int) $month->getMonth()] = $month;
$this->months[$month->getMonthNumber()] = $month;
return $this;
}

View File

@@ -11,22 +11,13 @@ namespace App\Model;
class TimesheetCountedStatistic
{
/**
* @var int
*/
protected $recordAmount = 0;
/**
* @var int
*/
protected $recordDuration = 0;
/**
* @var float
*/
protected $recordRate = 0.0;
/**
* @var float
*/
protected $recordInternalRate = 0.0;
private $recordAmount = 0;
private $recordDuration = 0;
private $recordRate = 0.0;
private $recordInternalRate = 0.0;
private $recordAmountBillable = 0;
private $recordDurationBillable = 0;
private $recordRateBillable = 0.0;
/**
* Returns the total amount of included timesheet records.
@@ -111,4 +102,34 @@ class TimesheetCountedStatistic
return $this;
}
public function getRecordAmountBillable(): int
{
return $this->recordAmountBillable;
}
public function setRecordAmountBillable(int $recordAmount): void
{
$this->recordAmountBillable = $recordAmount;
}
public function getDurationBillable(): int
{
return $this->recordDurationBillable;
}
public function setDurationBillable(int $recordDuration): void
{
$this->recordDurationBillable = $recordDuration;
}
public function getRateBillable(): float
{
return $this->recordRateBillable;
}
public function setRateBillable(float $recordRate): void
{
$this->recordRateBillable = $recordRate;
}
}

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\Reporting\ProjectInactive;
use App\Form\Type\DateTimePickerType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProjectInactiveForm extends AbstractType
{
/**
* Simplify cross linking between pages by removing the block prefix.
*
* @return null|string
*/
public function getBlockPrefix()
{
return null;
}
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->add('lastChange', DateTimePickerType::class, [
'label' => 'label.last_record_before',
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProjectInactiveQuery::class,
'timezone' => date_default_timezone_get(),
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,46 @@
<?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\ProjectInactive;
use App\Entity\User;
use DateTime;
final class ProjectInactiveQuery
{
/**
* @var DateTime
*/
private $lastChange;
/**
* @var User|null
*/
private $user;
public function __construct(DateTime $lastChange, User $user)
{
$this->lastChange = clone $lastChange;
$this->user = $user;
}
public function getUser(): ?User
{
return $this->user;
}
public function getLastChange(): DateTime
{
return $this->lastChange;
}
public function setLastChange(DateTime $lastChange): void
{
$this->lastChange = clone $lastChange;
}
}

View File

@@ -7,16 +7,22 @@
* file that was distributed with this source code.
*/
namespace App\Reporting\ProjectView;
namespace App\Reporting;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory;
use DateTime;
use DateTimeZone;
use Doctrine\DBAL\Types\Types;
use Exception;
final class ProjectViewService
final class ProjectStatisticService
{
private $repository;
private $timesheetRepository;
@@ -28,28 +34,58 @@ final class ProjectViewService
}
/**
* @param ProjectViewQuery $query
* @return ProjectViewModel[]
* @throws Exception
* @param ProjectInactiveQuery $query
* @return Project[]
*/
public function getProjectView(ProjectViewQuery $query): array
public function findInactiveProjects(ProjectInactiveQuery $query): array
{
$factory = new DateTimeFactory($query->getToday()->getTimezone());
$user = $query->getUser();
$today = clone $query->getToday();
$lastChange = clone $query->getLastChange();
$now = new DateTime('now', $lastChange->getTimezone());
$begin = $factory->getStartOfWeek($today);
$end = $factory->getEndOfWeek($today);
$startMonth = (clone $begin)->modify('first day of this month');
$endMonth = (clone $begin)->modify('last day of this month');
$qb2 = $this->repository->createQueryBuilder('t1');
$qb2
->select('1')
->from(Timesheet::class, 't')
->andWhere('p = t.project')
->andWhere($qb2->expr()->gte('t.begin', ':begin'))
;
$qb = $this->repository->createQueryBuilder('p');
$qb
->select('p AS project')
->addSelect('SUM(t.duration) AS totalDuration')
->addSelect('SUM(t.rate) AS totalRate')
->select('p, c')
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', true))
->andWhere($qb->expr()->eq('c.visible', true))
->andWhere($qb->expr()->not($qb->expr()->exists($qb2)))
->andWhere(
$qb->expr()->orX(
$qb->expr()->isNull('p.end'),
$qb->expr()->gte('p.end', ':project_end')
)
)
->setParameter('project_end', $now, Types::DATETIME_MUTABLE)
->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE)
;
$this->repository->addPermissionCriteria($qb, $user);
return $qb->getQuery()->getResult();
}
/**
* @param ProjectViewQuery $query
* @return Project[]
*/
public function findProjectsForView(ProjectViewQuery $query): array
{
$user = $query->getUser();
$today = clone $query->getToday();
$qb = $this->repository->createQueryBuilder('p');
$qb
->select('p')
->leftJoin('p.customer', 'c')
->leftJoin(Timesheet::class, 't', 'WITH', 'p.id = t.project')
->andWhere($qb->expr()->eq('p.visible', true))
->andWhere($qb->expr()->eq('c.visible', true))
->andWhere(
@@ -59,7 +95,6 @@ final class ProjectViewService
)
)
->addGroupBy('p')
->addGroupBy('t.project')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
;
@@ -69,7 +104,10 @@ final class ProjectViewService
}
if (!$query->isIncludeNoWork()) {
$qb->andHaving($qb->expr()->gt('totalDuration', 0));
$qb
->leftJoin(Timesheet::class, 't', 'WITH', 'p.id = t.project')
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
;
}
if (!$query->isIncludeNoBudget()) {
@@ -83,23 +121,59 @@ final class ProjectViewService
$this->repository->addPermissionCriteria($qb, $user);
$result = $qb->getQuery()->getResult();
return $qb->getQuery()->getResult();
}
/**
* @param User $user
* @param Project[] $projects
* @param DateTime|null $today
* @return ProjectViewModel[]
*/
public function getProjectView(User $user, array $projects, ?DateTime $today = null): array
{
$factory = new DateTimeFactory(new DateTimeZone($user->getTimezone()));
if (null === $today) {
$today = $factory->createDateTime();
}
$today = clone $today;
$begin = $factory->getStartOfWeek($today);
$end = $factory->getEndOfWeek($today);
$startMonth = (clone $begin)->modify('first day of this month');
$endMonth = (clone $begin)->modify('last day of this month');
$projectViews = [];
foreach ($result as $res) {
$entity = new ProjectViewModel($res['project']);
$entity->setDurationTotal($res['totalDuration'] ?? 0);
$entity->setRateTotal($res['totalRate'] ?? 0.00);
$projectViews[$entity->getProject()->getId()] = $entity;
foreach ($projects as $project) {
$projectViews[$project->getId()] = new ProjectViewModel($project);
}
$projectIds = array_keys($projectViews);
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, COUNT(t.id) as amount, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate, MAX(t.begin) as lastRecord')
->andWhere($qb->expr()->in('t.project', ':project'))
->groupBy('t.project')
->setParameter('project', array_values($projectIds))
;
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationTotal($row['duration']);
$projectViews[$row['id']]->setRateTotal($row['rate']);
$projectViews[$row['id']]->setTimesheetCounter($row['amount']);
if ($row['lastRecord'] !== null) {
// might be the wrong timezone
$projectViews[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
}
}
// values for today
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) = :starting_date')
->groupBy('t.project')
@@ -109,13 +183,13 @@ final class ProjectViewService
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$projectViews[$row['id']]->setDurationDay($row['duration']);
$projectViews[$row['id']]->setDurationDay($row['duration'] ?? 0);
}
// values for the current week
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date')
->groupBy('t.project')
@@ -132,7 +206,7 @@ final class ProjectViewService
// values for the current month
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month')
->groupBy('t.project')
@@ -146,10 +220,10 @@ final class ProjectViewService
$projectViews[$row['id']]->setDurationMonth($row['duration']);
}
// values for the all time (not exported)
// values for all time (not exported)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported')
->groupBy('t.project')
@@ -163,10 +237,10 @@ final class ProjectViewService
$projectViews[$row['id']]->setNotExportedRate($row['rate']);
}
// values for the all time (not exported and billable)
// values for all time (not exported and billable)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported')
->andWhere('t.billable = :billable')
@@ -182,6 +256,23 @@ final class ProjectViewService
$projectViews[$row['id']]->setNotBilledRate($row['rate']);
}
// values for all time (none billable)
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere('t.billable = :billable')
->groupBy('t.project')
->setParameter('billable', true, Types::BOOLEAN)
->setParameter('project', array_values($projectIds))
;
$result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) {
$projectViews[$row['id']]->setBillableDuration($row['duration']);
$projectViews[$row['id']]->setBillableRate($row['rate']);
}
return array_values($projectViews);
}
}

View File

@@ -10,49 +10,24 @@
namespace App\Reporting\ProjectView;
use App\Entity\Project;
use DateTime;
final class ProjectViewModel
{
/**
* @var Project
*/
private $project;
/**
* @var int
*/
private $timesheetCounter = 0;
private $durationDay = 0;
/**
* @var int
*/
private $durationWeek = 0;
/**
* @var int
*/
private $durationMonth = 0;
/**
* @var int
*/
private $durationTotal = 0;
/**
* @var float
*/
private $rateTotal = 0.00;
/**
* @var int
*/
private $notExportedDuration = 0;
/**
* @var float
*/
private $notExportedRate = 0.00;
/**
* @var int
*/
private $notBilledDuration = 0;
/**
* @var float
*/
private $notBilledRate = 0.00;
private $billableDuration = 0;
private $billableRate = 0.00;
private $lastRecord;
public function __construct(Project $project)
{
@@ -64,6 +39,16 @@ final class ProjectViewModel
return $this->project;
}
public function getTimesheetCounter(): int
{
return $this->timesheetCounter;
}
public function setTimesheetCounter(int $timesheetCounter): void
{
$this->timesheetCounter = $timesheetCounter;
}
public function getDurationDay(): int
{
return $this->durationDay;
@@ -144,6 +129,26 @@ final class ProjectViewModel
$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;
@@ -153,4 +158,14 @@ final class ProjectViewModel
{
$this->rateTotal = $rateTotal;
}
public function getLastRecord(): ?DateTime
{
return $this->lastRecord;
}
public function setLastRecord(DateTime $lastRecord): void
{
$this->lastRecord = $lastRecord;
}
}

View File

@@ -50,6 +50,7 @@ final class ReportingService
}
if ($this->security->isGranted('budget_project')) {
$event->addReport(new Report('project_view', 'report_project_view', 'report_project_view', 'project'));
$event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
}
$this->dispatcher->dispatch($event);

View File

@@ -20,6 +20,7 @@ use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
@@ -97,9 +98,9 @@ class ActivityRepository extends EntityRepository
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration')
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.internalRate) as internal_rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->where('t.activity = :activity')
->setParameter('activity', $activity)
;
@@ -115,6 +116,26 @@ class ActivityRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->where('t.activity = :activity')
->andWhere('t.billable = :billable')
->setParameter('activity', $activity)
->setParameter('billable', true, Types::BOOLEAN)
;
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $timesheetResult) {
$stats->setDurationBillable($timesheetResult['duration']);
$stats->setRateBillable($timesheetResult['rate']);
$stats->setRecordAmountBillable($timesheetResult['amount']);
}
return $stats;
}

View File

@@ -22,6 +22,7 @@ use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
@@ -78,22 +79,16 @@ class CustomerRepository extends EntityRepository
return $this->count([]);
}
/**
* Retrieves statistics for one customer.
*
* @param Customer $customer
* @return CustomerStatistic
*/
public function getCustomerStatistics(Customer $customer)
public function getCustomerStatistics(Customer $customer): CustomerStatistic
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration')
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.internalRate) as internal_rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('p.customer = :customer')
->setParameter('customer', $customer)
;
@@ -109,6 +104,27 @@ class CustomerRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->andWhere('p.customer = :customer')
->andWhere('t.billable = :billable')
->setParameter('customer', $customer)
->setParameter('billable', true, Types::BOOLEAN)
;
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $timesheetResult) {
$stats->setDurationBillable($timesheetResult['duration']);
$stats->setRateBillable($timesheetResult['rate']);
$stats->setRecordAmountBillable($timesheetResult['amount']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('COUNT(a.id) as amount')

View File

@@ -22,6 +22,7 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
@@ -84,18 +85,14 @@ class ProjectRepository extends EntityRepository
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration')
->addSelect('SUM(t.rate) as rate')
->addSelect('SUM(t.internalRate) as internal_rate')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('t.project = :project')
->setParameter('project', $project)
;
if (null !== $begin) {
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin);
}
// to calculate a budget at a certain point in time
if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end);
@@ -112,6 +109,32 @@ class ProjectRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount')
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->andWhere('t.project = :project')
->andWhere('t.billable = :billable')
->setParameter('project', $project)
->setParameter('billable', true, Types::BOOLEAN)
;
// to calculate a budget at a certain point in time
if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end);
}
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
if (null !== $timesheetResult) {
$stats->setDurationBillable($timesheetResult['duration']);
$stats->setRateBillable($timesheetResult['rate']);
$stats->setRecordAmountBillable($timesheetResult['amount']);
}
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->from(Activity::class, 'a')

View File

@@ -304,6 +304,11 @@ class BaseQuery
return $this->bookmark;
}
public function hasBookmark(): bool
{
return null !== $this->bookmark;
}
public function setName(string $name): void
{
$this->name = $name;

View File

@@ -245,10 +245,10 @@ class TimesheetRepository extends EntityRepository
return $this->getDailyStats($user, $begin, $end);
case self::STATS_QUERY_DURATION:
$what = 'SUM(t.duration)';
$what = 'COALESCE(SUM(t.duration), 0)';
break;
case self::STATS_QUERY_RATE:
$what = 'SUM(t.rate)';
$what = 'COALESCE(SUM(t.rate), 0)';
break;
case self::STATS_QUERY_USER:
$what = 'COUNT(DISTINCT(t.user))';
@@ -271,10 +271,16 @@ class TimesheetRepository extends EntityRepository
*/
protected function queryThisMonth($select, User $user)
{
$begin = new DateTime('first day of this month 00:00:00');
$end = new DateTime('last day of this month 23:59:59');
try {
$timezone = new \DateTimeZone($user->getTimezone());
$begin = new DateTime('first day of this month 00:00:00', $timezone);
$end = new DateTime('last day of this month 23:59:59', $timezone);
return $this->queryTimeRange($select, $begin, $end, $user);
return $this->queryTimeRange($select, $begin, $end, $user);
} catch (\Exception $ex) {
}
return 0;
}
/**
@@ -314,20 +320,13 @@ class TimesheetRepository extends EntityRepository
return empty($result) ? 0 : $result;
}
/**
* Fetch statistic data for one user.
*
* @param User $user
* @return TimesheetStatistic
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function getUserStatistics(User $user)
public function getUserStatistics(User $user): TimesheetStatistic
{
$durationTotal = $this->getStatistic(self::STATS_QUERY_DURATION, null, null, $user);
$recordsTotal = $this->getStatistic(self::STATS_QUERY_AMOUNT, null, null, $user);
$rateTotal = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user);
$amountMonth = $this->queryThisMonth('SUM(t.rate)', $user);
$durationMonth = $this->queryThisMonth('SUM(t.duration)', $user);
$amountMonth = $this->queryThisMonth('COALESCE(SUM(t.rate), 0)', $user);
$durationMonth = $this->queryThisMonth('COALESCE(SUM(t.duration), 0)', $user);
$firstEntry = $this->getEntityManager()
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
->setParameter('user', $user)
@@ -352,42 +351,15 @@ class TimesheetRepository extends EntityRepository
* @param DateTime|null $end
* @return Year[]
*/
public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null)
public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('SUM(t.rate) as rate, SUM(t.duration) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year')
->from(Timesheet::class, 't')
;
if (!empty($begin)) {
$qb->andWhere($qb->expr()->gte('t.begin', ':from'))
->setParameter('from', $begin);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.begin'));
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lte('t.end', ':to'))
->setParameter('to', $end);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $user) {
$qb->andWhere('t.user = :user')
->setParameter('user', $user);
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month');
/** @var Year[] $years */
$years = [];
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, null);
foreach ($qb->getQuery()->execute() as $statRow) {
$curYear = $statRow['year'];
$curMonth = (int) $statRow['month'];
if (!isset($years[$curYear])) {
$year = new Year($curYear);
@@ -398,15 +370,74 @@ class TimesheetRepository extends EntityRepository
$years[$curYear] = $year;
}
$month = new Month($statRow['month']);
$month->setTotalDuration((int) $statRow['duration'])
->setTotalRate((float) $statRow['rate']);
$years[$curYear]->setMonth($month);
$month = $years[$curYear]->getMonth($curMonth);
$month->setTotalDuration((int) $statRow['duration']);
$month->setTotalRate((float) $statRow['rate']);
}
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, true);
foreach ($qb->getQuery()->execute() as $statRow) {
$curYear = $statRow['year'];
$curMonth = (int) $statRow['month'];
if (!isset($years[$curYear])) {
$year = new Year($curYear);
for ($i = 1; $i < 13; $i++) {
$month = $i < 10 ? '0' . $i : (string) $i;
$year->setMonth(new Month($month));
}
$years[$curYear] = $year;
}
$month = $years[$curYear]->getMonth($curMonth);
$month->setBillableDuration((int) $statRow['duration']);
$month->setBillableRate((float) $statRow['rate']);
}
return $years;
}
private function getMonthlyStatsQuery(User $user = null, ?DateTime $begin = null, ?DateTime $end = null, ?bool $billable = null): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Timesheet::class, 't');
$qb->select('COALESCE(SUM(t.rate), 0) as rate, COALESCE(SUM(t.duration), 0) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year');
if (!empty($begin)) {
$qb->andWhere($qb->expr()->gte('t.begin', ':from'));
$qb->setParameter('from', $begin);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.begin'));
}
if (!empty($end)) {
$qb->andWhere($qb->expr()->lte('t.end', ':to'));
$qb->setParameter('to', $end);
} else {
$qb->andWhere($qb->expr()->isNotNull('t.end'));
}
if (null !== $user) {
$qb->andWhere('t.user = :user');
$qb->setParameter('user', $user);
}
if (null !== $billable) {
$qb->andWhere('t.billable = :billable');
$qb->setParameter('billable', $billable);
}
$qb
->orderBy('year', 'DESC')
->addOrderBy('month', 'ASC')
->groupBy('year')
->addGroupBy('month')
;
return $qb;
}
/**
* In case this method is called with one timezone and the results are from another timezone,
* it might return rows outside the time-range.
@@ -481,6 +512,7 @@ class TimesheetRepository extends EntityRepository
$results[$dateKey] = [
'rate' => 0,
'duration' => 0,
'billable' => 0, // duration
'month' => $beginTmp->format('n'),
'year' => $beginTmp->format('Y'),
'day' => $beginTmp->format('j'),
@@ -496,6 +528,9 @@ class TimesheetRepository extends EntityRepository
$results[$dateKey]['rate'] += $rate;
$results[$dateKey]['duration'] += $duration;
if ($result->isBillable()) {
$results[$dateKey]['billable'] += $duration;
}
$detailsId =
$result->getProject()->getCustomer()->getId()
. '_' . $result->getProject()->getId()
@@ -508,11 +543,15 @@ class TimesheetRepository extends EntityRepository
'activity' => $result->getActivity(),
'duration' => 0,
'rate' => 0,
'billable' => 0, // duration
];
}
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
if ($result->isBillable()) {
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
}
}
$beginTmp = $newDateBegin;
@@ -562,6 +601,7 @@ class TimesheetRepository extends EntityRepository
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
$dateTime->setTime(0, 0, 0);
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
$day->setTotalDurationBillable($statRow['billable']);
$day->setDetails($statRow['details']);
$dateKey = $dateTime->format('Ymd');
// make sure entries from other timezones are filtered

View File

@@ -73,4 +73,26 @@ final class ThemeExtension implements RuntimeExtensionInterface
return $event->getTranslations();
}
public function getProgressbarClass(float $percent, ?bool $reverseColors = false): string
{
$colors = ['xl' => 'progress-bar-danger', 'l' => 'progress-bar-warning', 'm' => 'progress-bar-success', 's' => 'progress-bar-primary', 'e' => 'progress-bar-info'];
if (true === $reverseColors) {
$colors = ['s' => 'progress-bar-danger', 'm' => 'progress-bar-warning', 'l' => 'progress-bar-success', 'xl' => 'progress-bar-primary', 'e' => 'progress-bar-info'];
}
if ($percent > 90) {
$class = $colors['xl'];
} elseif ($percent > 70) {
$class = $colors['l'];
} elseif ($percent > 50) {
$class = $colors['m'];
} elseif ($percent > 30) {
$class = $colors['s'];
} else {
$class = $colors['e'];
}
return $class;
}
}

View File

@@ -28,6 +28,7 @@ class RuntimeExtensions extends AbstractExtension
return [
new TwigFunction('trigger', [ThemeExtension::class, 'trigger'], ['needs_environment' => true]),
new TwigFunction('actions', [ThemeExtension::class, 'actions']),
new TwigFunction('progressbar_color', [ThemeExtension::class, 'getProgressbarClass']),
new TwigFunction('javascript_translations', [ThemeExtension::class, 'getJavascriptTranslations']),
new TwigFunction('active_timesheets', [TimesheetExtension::class, 'activeEntries']),
new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']),