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

@@ -41,9 +41,13 @@ export default class KimaiActiveRecords extends KimaiPlugin {
const handle = function() { self.reloadActiveRecords(); };
document.addEventListener('kimai.timesheetUpdate', handle);
document.addEventListener('kimai.timesheetDelete', handle);
document.addEventListener('kimai.activityUpdate', handle);
document.addEventListener('kimai.activityDelete', handle);
document.addEventListener('kimai.projectUpdate', handle);
document.addEventListener('kimai.projectDelete', handle);
document.addEventListener('kimai.customerUpdate', handle);
document.addEventListener('kimai.customerDelete', handle);
}
emptyList() {

View File

@@ -42,9 +42,13 @@ export default class KimaiRecentActivities extends KimaiPlugin {
document.addEventListener('kimai.recentActivities', handle);
document.addEventListener('kimai.timesheetUpdate', handle);
document.addEventListener('kimai.timesheetDelete', handle);
document.addEventListener('kimai.activityUpdate', handle);
document.addEventListener('kimai.activityDelete', handle);
document.addEventListener('kimai.projectUpdate', handle);
document.addEventListener('kimai.projectDelete', handle);
document.addEventListener('kimai.customerUpdate', handle);
document.addEventListener('kimai.customerDelete', handle);
}
emptyList() {

View File

@@ -42,6 +42,10 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
href = jQuery(this).attr('href');
}
if (href === undefined || href === null || href === '') {
return;
}
callback(href);
});
}

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.098eaae1.js",
"build/0.79dbdbb9.js",
"build/1.32489d92.js",
"build/app.cc583d15.js"
"build/app.38660438.js"
],
"css": [
"build/app.bffbc2eb.css"
@@ -53,7 +53,7 @@
"build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
"build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP",
"build/app.cc583d15.js": "sha384-Hotpowsx1ghAlRDochFNc750cMjhD3nx/GnA/lYYjE6KKU1OesPXhbkQMrqfoUUF",
"build/app.38660438.js": "sha384-YsPmoVY6JNGaTWrQrdpePjMTA5lIi1JrL21aiU6Ullhkwt0aBz59ZTn70HTKd8yC",
"build/app.bffbc2eb.css": "sha384-Wt0GyrHU40HyNqgnwmxa18+VreRdfpgpOcHFB3DSCHLdGpH5sM5jCTjsYSrtrdgj",
"build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",

View File

@@ -3,7 +3,7 @@
"build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"build/app.css": "build/app.bffbc2eb.css",
"build/app.js": "build/app.cc583d15.js",
"build/app.js": "build/app.38660438.js",
"build/calendar.css": "build/calendar.1408f57e.css",
"build/calendar.js": "build/calendar.070aab88.js",
"build/chart.js": "build/chart.34d60a88.js",

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']),

View File

@@ -4,10 +4,10 @@
{{ widgets.page_actions(event.actions) }}
{% endmacro %}
{% macro activity(activity, view, options) %}
{% macro activity(activity, view, isTable) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'activity', view, {'activity': activity}) %}
{% if view == 'index' or view == 'custom' %}
{% if view == 'index' or view == 'custom' or isTable is not null %}
{{ widgets.table_actions(event.actions) }}
{% else %}
{{ widgets.page_actions(event.actions) }}

View File

@@ -3,7 +3,7 @@
{% import "activity/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.activity(activity, 'details') }}{% endblock %}
{% block page_actions %}{{ actions.activity(activity, 'activity_details') }}{% endblock %}
{% block main %}
{% set can_edit = is_granted('edit', activity) %}
@@ -42,7 +42,7 @@
</tr>
{% endif %}
{% if not activity.global %}
<tr class="{{ widgets.class_customer_row(activity.project.customer, now) }}">
<tr {{ widgets.customer_row_attr(activity.project.customer, now) }}>
<th>{{ 'label.customer'|trans }}</th>
<td>
{{ widgets.label_customer(activity.project.customer) }}
@@ -54,7 +54,7 @@
{{ customerActions.customer(activity.project.customer, 'custom') }}
</td>
</tr>
<tr class="{{ widgets.class_project_row(activity.project, now) }}">
<tr {{ widgets.project_row_attr(activity.project, now) }}>
<th>{{ 'label.project'|trans }}</th>
<td>
{{ widgets.label_project(activity.project) }}
@@ -78,7 +78,11 @@
{% endembed %}
{% if stats is not null %}
{{ include('activity/embed_budget.html.twig', {'activity': activity, 'stats': stats}) }}
{% set currency = null %}
{% if activity.project is not null %}
{% set currency = activity.project.customer.currency %}
{% endif %}
{{ include('embeds/budgets.html.twig', {'entity': activity, 'stats': stats, 'currency': currency}) }}
{% endif %}
{% if can_edit %}
@@ -102,7 +106,7 @@
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.activityUpdate kimai.teamUpdate kimai.activityTeamUpdate kimai.projectTeamUpdate kimai.customerTeamUpdate kimai.projectUpdate kimai.rateUpdate');
KimaiReloadPageWidget.create('kimai.activityUpdate kimai.teamUpdate kimai.activityTeamUpdate kimai.projectTeamUpdate kimai.customerTeamUpdate kimai.customerUpdate kimai.projectUpdate kimai.rateUpdate');
});
</script>
{% endblock %}

View File

@@ -1,34 +0,0 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set params = {
'%activity%': activity.name,
'%project%': '-',
'%customer%': '-',
'%records%': stats.recordAmount,
'%duration%': stats.recordDuration|duration
} %}
{% set currency = null %}
{% if activity.project is not null %}
{% set currency = activity.project.customer.currency %}
{% endif %}
{% if activity.project is not null %}
{% set params = params|merge({
'%project%': activity.project.name,
'%customer%': activity.project.customer.name,
}) %}
{% endif %}
<p>
{{ 'admin_activity.short_stats'|trans(params) }}
{{ 'label.rate_internal'|trans }}: {{ stats.recordInternalRate|money(currency) }}.
</p>
{{ progress.progressbar(activity.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ activity.budget|money(currency) ) }}
{{ progress.progressbar(activity.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ activity.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -37,16 +37,10 @@
{% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.activityUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate kimai.activityTeamUpdate'}) }}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate kimai.activityDelete kimai.activityTeamUpdate'}) }}
{% for entry in entries %}
{% set class = widgets.class_activity_row(entry, now) %}
{% set dataHref = '' %}
{% if is_granted('view', entry) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('activity_details', {'id': entry.id}) %}
{% endif %}
<tr class="{{ class }}" data-href="{{ dataHref }}">
<tr {{ widgets.activity_row_attr(entry, now) }}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{# only none-global activities have a project and customer assigned #}

View File

@@ -2,7 +2,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as actions %}
{% block page_actions %}{{ actions.customer(customer, 'details') }}{% endblock %}
{% block page_actions %}{{ actions.customer(customer, 'customer_details') }}{% endblock %}
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
{% block main %}
@@ -121,7 +121,7 @@
{{ render(controller('App\\Controller\\CustomerController::projectsAction', {'customer': customer.id, 'page': 1})) }}
{% if stats is not null %}
{{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }}
{{ include('embeds/budgets.html.twig', {'entity': customer, 'stats': stats, 'currency': customer.currency}) }}
{% endif %}
{% if can_edit %}
@@ -150,7 +150,7 @@
{{ parent() }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.projectUpdate kimai.rateUpdate');
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.rateUpdate');
});
</script>
{% endblock %}

View File

@@ -1,26 +0,0 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'customer': customer, 'stats': stats} %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set currency = customer.currency %}
{% set params = {
'%activity%': stats.activityAmount,
'%project%': stats.projectAmount,
'%customer%': customer.name,
'%records%': stats.recordAmount,
'%duration%': stats.recordDuration|duration,
'%rate%': stats.recordRate|money(currency),
'%internal_rate%': stats.recordInternalRate|money(currency)
} %}
<p>
{{ 'admin_customer.short_stats'|trans(params) }}
{{ 'label.rate_internal'|trans }}: {{ stats.recordInternalRate|money(currency) }}.
</p>
{{ progress.progressbar(customer.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ customer.budget|money(currency) ) }}
{{ progress.progressbar(customer.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ customer.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -3,7 +3,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'menu.admin_project'|trans }}{% endblock %}
{% block box_attributes %}
id="project_list_box" data-href="{{ path('customer_projects', {'id': customer.id}) }}" data-reload="kimai.projectUpdate"
id="project_list_box" data-href="{{ path('customer_projects', {'id': customer.id}) }}" data-reload="kimai.projectUpdate kimai.projectDelete"
{% endblock %}
{% block box_tools %}
{% if is_granted('view_reporting') and is_granted('budget_project') %}
@@ -35,7 +35,7 @@
</thead>
<tbody>
{% for project in projects %}
<tr class="{{ widgets.class_project_row(project, now) }}">
<tr {{ widgets.project_row_attr(project, now) }}>
<td>{{ widgets.label_project(project) }}</td>
<td class="hidden-xs">{{ project.comment|comment1line(not app.user.smallLayout) }}</td>
<td class="w-min text-center">
@@ -47,7 +47,7 @@
</td>
<td class="w-min text-center">{{ widgets.label_visible(project.visible) }}</td>
<td class="w-min text-center">{{ widgets.badge_team_access(project.teams) }}</td>
<td class="actions">{{ actions.project(project, 'custom') }}</td>
<td class="actions">{{ actions.project(project, 'customer_details', true) }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -48,16 +48,10 @@
{% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.customerUpdate kimai.customerTeamUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerTeamUpdate'}) }}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerDelete kimai.customerTeamUpdate'}) }}
{% for entry in entries %}
{% set class = widgets.class_customer_row(entry, now) %}
{% set dataHref = '' %}
{% if is_granted('view', entry) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('customer_details', {'id': entry.id}) %}
{% endif %}
<tr class="{{ class }}" data-href="{{ dataHref }}">
<tr {{ widgets.customer_row_attr(entry, now) }}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td>

View File

@@ -0,0 +1,23 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.timeBudget'|trans }}{% endblock %}
{% block box_attributes %}id="time_budget_box"{% endblock %}
{% block box_body %}
{{ progress.progressbar(stats.recordDuration, stats.durationBillable, 'label.billable'|trans, stats.durationBillable|duration ~ ' / ' ~ stats.recordDuration|duration, true) }}
{% if entity.timeBudget > 0 %}
{{ progress.progressbar(entity.timeBudget, stats.durationBillable, 'label.timeBudget'|trans, stats.durationBillable|duration ~ ' / ' ~ entity.timeBudget|duration) }}
{% endif %}
{% endblock %}
{% endembed %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{{ progress.progressbar(stats.recordRate, stats.rateBillable, 'label.billable'|trans, stats.rateBillable|money(currency) ~ ' / ' ~ stats.recordRate|money(currency), true) }}
{% if entity.budget > 0 %}
{{ progress.progressbar(entity.budget, stats.rateBillable, 'label.budget'|trans, stats.rateBillable|money(currency) ~ ' / ' ~ entity.budget|money(currency)) }}
{% endif %}
{{ progress.progressbar(stats.rateBillable, stats.recordInternalRate, 'label.rate_internal'|trans, stats.recordInternalRate|money(currency) ~ ' / ' ~ stats.rateBillable|money(currency)) }}
{% endblock %}
{% endembed %}

View File

@@ -11,7 +11,7 @@
'description': {'class': 'hidden-xs hidden-sm hidden-md timesheet-description', 'orderBy': false},
'unit_price': {'class': 'hidden hidden-xs text-nowrap', 'orderBy': false},
'duration': {'class': 'text-nowrap', 'orderBy': false},
'rate_internal': {'class': 'hidden-xs text-nowrap', 'orderBy': false},
'rate_internal': {'class': 'hidden text-nowrap', 'orderBy': false},
'total_rate': {'class': 'text-nowrap', 'orderBy': false},
'exported': {'class': 'alwaysVisible', 'orderBy': false},
} %}
@@ -32,6 +32,7 @@
{% block main %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/search.html.twig" as search %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'export.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %}
@@ -46,43 +47,13 @@
{% if form.users is defined %}
{{ form_row(form.users) }}
{% endif %}
{{ form_row(form.billable) }}
{{ form_row(form.exported) }}
{{ form_row(form.state) }}
{{ form_row(form.markAsExported) }}
{% endblock %}
{% block box_footer%}
{% set buttons = {} %}
{% for button in renderer %}
{% set title = button.title %}
{% set group = [] %}
{% if buttons[(title)] is defined %}
{% set group = buttons[(title)] %}
{% endif %}
{% set group = group|merge([button]) %}
{% set buttons = buttons|merge({(title): group}) %}
{% endfor %}
<div class="btn-group" id="export-buttons" role="group">
{% for group in buttons %}
{% set button = group.0 %}
{% if group|length == 1 %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
{{ ('button.' ~ button.title)|trans }}
</button>
{% elseif group|length > 1 %}
<div class="btn-group">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ ('button.' ~ button.title)|trans }} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for button in group %}
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ (button.id)|trans({}, 'export') }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
</div>
{{ form_widget(form.preview) }}
{{ search.searchButton(form) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %}
@@ -91,28 +62,100 @@
{% if entries is empty %}
{{ widgets.nothing_found() }}
{% else %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}
{{ 'button.preview'|trans }}: {{ 'export.title'|trans }}
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_footer %}
{% set buttons = {} %}
{% for button in renderer %}
{% set title = button.title %}
{% set group = [] %}
{% if buttons[(title)] is defined %}
{% set group = buttons[(title)] %}
{% endif %}
{% set group = group|merge([button]) %}
{% set buttons = buttons|merge({(title): group}) %}
{% endfor %}
<div class="btn-group" id="export-buttons" role="group">
{% for group in buttons %}
{% set button = group.0 %}
{% if group|length == 1 %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
{{ ('button.' ~ button.title)|trans }}
</button>
{% elseif group|length > 1 %}
<div class="btn-group">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{{ ('button.' ~ button.title)|trans }} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for button in group %}
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ (button.id)|trans({}, 'export') }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endfor %}
</div>
{% endblock %}
{% block box_body %}
<table class="table table-hover dataTable">
<thead>
<tr>
<th>{{ 'label.customer'|trans }}</th>
<th class="w-min text-center hidden-xs">{{ 'label.duration'|trans }}</th>
{# <th class="w-min text-right hidden-xs">{{ 'label.internalRate'|trans }}</th> #}
<th class="w-min text-right">{{ 'label.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in by_customer %}
{% set currency = row.customer.currency %}
<tr>
<td>
{{ widgets.label_customer(row.customer) }}
</td>
<td class="w-min text-center hidden-xs">
{{ row.duration|duration(decimal) }}
</td>
{#
<td class="w-min text-right hidden-xs">
{{ row.internalRate|money(currency) }}
</td>
#}
<td class="w-min text-right">
{{ row.rate|money(currency) }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endembed %}
{% set itemsAmount = entries|length %}
{% if preview_limit %}
{% set entries = entries|slice(0, preview_limit) %}
{% endif %}
{{ tables.datatable_header(tableName, columns, query, {}) }}
{% set totalAmount = {} %}
{% set totalInternalAmount = {} %}
{% set totalDuration = 0 %}
{% for entry in entries %}
{% set currency = entry.project.customer.currency %}
{% set duration = entry.duration|duration %}
{% if totalAmount[currency] is not defined %}
{% set totalAmount = totalAmount|merge({(currency): 0}) %}
{% set totalInternalAmount = totalInternalAmount|merge({(currency): 0}) %}
{% endif %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
{% set totalDuration = totalDuration + entry.duration %}
{% set totalAmount = totalAmount|merge({(currency): totalAmount[currency] + entry.rate}) %}
{% set totalInternalAmount = totalInternalAmount|merge({(currency): totalInternalAmount[currency] + entry.internalRate}) %}
<tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">
{{ entry.begin|date_short }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
{{ widgets.label_user(entry.user) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }}
<br>
@@ -130,7 +173,7 @@
{{ rate|money(currency) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
{{ duration }}
{{ entry.duration|duration(decimal) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}">
{{ entry.internalRate|money(currency) }}
@@ -161,30 +204,11 @@
</td>
</tr>
{% endfor %}
<tr>
<th class="{{ tables.data_table_column_class(tableName, columns, 'date') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">
{{ totalDuration|duration }}
</th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}">
{% for currency, amount in totalInternalAmount %}
{{ amount|money(currency) }}
{% if not loop.last %}<br>{% endif %}
{% endfor %}
</th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">
{% for currency, amount in totalAmount %}
{{ amount|money(currency) }}
{% if not loop.last %}<br>{% endif %}
{% endfor %}
</th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}"></th>
</tr>
{% if preview_limit and itemsAmount > preview_limit %}
<tr class="warning">
<td colspan="10">&raquo; {{ 'preview.skipped_rows'|trans({'%rows%': (itemsAmount - preview_limit)}) }}</td>
</tr>
{% endif %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endif %}
@@ -201,7 +225,7 @@
var message = '{{ 'export.clear_all'|trans }}';
var hasActive = true;
if (!$('#export-toggle-button').hasClass('export-off')) {
if (!jQuery('#export-toggle-button').hasClass('export-off')) {
message = '{{ 'export.mark_all'|trans }}';
hasActive = false;
}
@@ -210,8 +234,8 @@
if (!value) {
return;
}
var btn = $(button);
var exportButtons = $('.exportBtn');
var btn = jQuery(button);
var exportButtons = jQuery('.exportBtn');
// disabling does not yet work...
if (exportButtons.length > 0) {
btn.addClass('disabled');
@@ -219,8 +243,8 @@
// ... as the clicks are asynchronous and the each comes back too early ...
exportButtons.each(function () {
if (hasActive === $(this).hasClass('active')) {
$(this).click();
if (hasActive === jQuery(this).hasClass('active')) {
jQuery(this).click();
}
});
@@ -254,11 +278,11 @@
} else {
button.button('clean');
}
if ($('select#exported').val() !== '{{ constant('App\\Repository\\Query\\TimesheetQuery::STATE_ALL') }}') {
if (jQuery('select#exported').val() !== '{{ constant('App\\Repository\\Query\\TimesheetQuery::STATE_ALL') }}') {
button.closest('tr').hide('ease', function() {
$(this).remove();
if($(this).closest('table').find('tbody tr:visible').length === 0) {
$('#export-buttons button').prop('disabled', true);
jQuery(this).remove();
if(jQuery(this).closest('table').find('tbody tr:visible').length === 0) {
jQuery('#export-buttons button').prop('disabled', true);
}
});
}
@@ -286,24 +310,24 @@
}
document.addEventListener('kimai.initialized', function() {
$('body').on('click', '.exportBtn', function() {
var button = $(this);
jQuery('body').on('click', '.exportBtn', function() {
var button = jQuery(this);
var id = button.attr('data-timesheet');
updateTimesheetExportState(button, id, !button.hasClass('active'));
});
$('#export-toggle-button').on('click', function () {
jQuery('#export-toggle-button').on('click', function () {
confirmToggleState(this);
});
$('body').on('click', '#export-buttons .startExportBtn', function() {
$('#renderer').val($(this).attr('data-type'));
var $form = $("#export-form");
jQuery('body').on('click', '#export-buttons .startExportBtn', function() {
jQuery('#renderer').val(jQuery(this).attr('data-type'));
var $form = jQuery("#export-form");
var prevAction = $form.attr('action');
var prevMethod = $form.attr('method');
$form.attr('target', '_blank').attr('method', 'POST').attr('action', '{{ path('export_data') }}');
$form.submit();
$('#renderer').val('');
jQuery('#renderer').val('');
$form.removeAttr('target').attr('action', prevAction).attr('method', prevMethod);
});
});

View File

@@ -85,7 +85,7 @@
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_footer %}
<a href="#" onclick="return saveAllInvoices(this);" class="btn btn-primary" id="create_all_invoices">
<a href="#" onclick="return saveAllInvoices(this);" class="btn btn-success" id="create_all_invoices">
{{ 'action.save_all'|trans }}
</a>
{% endblock %}
@@ -109,8 +109,8 @@
</td>
<td class="w-min text-center">
{{ widgets.action_button('show', {'url': '#invoice_preview_details_' ~ model.customer.id, 'title': 'timesheet.all'|trans, 'class': 'btn btn-sm hidden-xs hidden-sm'}, 'link') }}
{{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }}
{{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'primary') }}
{{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}) }}
{{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }}
</td>
<td class="w-min text-center hidden-xs">
{{ model.calculator.timeWorked|duration(isDecimal) }}

View File

@@ -1,21 +1,10 @@
{% macro progressbar(max, current, title, subTitle) %}
{% macro progressbar(max, current, title, subTitle, reverseColors) %}
{% set percentReached = 0 %}
{% if max > 0 %}
{% set percentReached = (current / (max / 100)) %}
{% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %}
{% if percentReached > 90 %}
{% set class = "progress-bar-danger" %}
{% elseif percentReached > 70 %}
{% set class = "progress-bar-warning" %}
{% elseif percentReached > 50 %}
{% set class = "progress-bar-success" %}
{% elseif percentReached > 30 %}
{% set class = "progress-bar-primary" %}
{% endif %}
{% if width > 100 %}
{% set width = 100 %}
{% endif %}
@@ -35,36 +24,25 @@
</span>
</div>
<div class="progress">
<div class="progress-bar {{ class }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
<div class="progress-bar {{ progressbar_color(percentReached, reverseColors) }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
</div>
</div>
{% endmacro %}
{% macro progressbar_small(max, current, leftValue, leftFormatted) %}
{% macro progressbar_small(max, current, leftValue, leftFormatted, reverseColors) %}
{% set percentReached = 0 %}
{% if max > 0 %}
{% set percentReached = (current / (max / 100)) %}
{% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %}
{% if percentReached > 90 %}
{% set class = "progress-bar-danger" %}
{% elseif percentReached > 70 %}
{% set class = "progress-bar-warning" %}
{% elseif percentReached > 50 %}
{% set class = "progress-bar-success" %}
{% elseif percentReached > 30 %}
{% set class = "progress-bar-primary" %}
{% endif %}
{% if width > 100 %}
{% set width = 100 %}
{% endif %}
<div class="progress-group">
<div class="progress progress-sm">
<div class="progress-bar {{ class }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
<div class="progress-bar {{ progressbar_color(percentReached, reverseColors) }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
</div>
{% if leftValue is not null and leftValue > 0 %}
<small>{{ 'stats.percentUsedLeft'|trans({'%percent%': percentReached|number_format(0), '%left%': leftFormatted}) }}</small>

View File

@@ -504,17 +504,105 @@
{% endif %}
{% endmacro %}
{# To be used mainly in <tr class="{{ class_customer_row(customer, now) }}"> #}
{#
To be used like this:
<tr class="{{ class_customer_row(customer, now) }}">
#}
{% macro class_customer_row(customer, now) %}
{%- if not customer.visible %}warning{% endif -%}
{% endmacro %}
{# To be used mainly in <tr class="{{ class_project_row(project, now) }}"> #}
{#
To be used like this:
<tr {{ customer_row_attr(customer, now) }}>
#}
{% macro customer_row_attr(customer, now) %}
{%- set class = _self.class_customer_row(customer, now) %}
{% set dataHref = '' %}
{% if is_granted('view', customer) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('customer_details', {'id': customer.id}) %}
{% endif -%}
class="{{ class }}" data-href="{{ dataHref }}"
{% endmacro %}
{#
To be used like this:
<tr class="{{ class_project_row(project, now) }}">
#}
{% macro class_project_row(project, now) %}
{%- if not project.visible or (project.end is not null and project.end < now) %}warning{% endif -%}
{% endmacro %}
{# To be used mainly in <tr class="{{ class_activity_row(activity, now) }}"> #}
{#
To be used like this:
<tr {{ project_row_attr(project, now) }}>
#}
{% macro project_row_attr(project, now) %}
{%- set class = _self.class_project_row(project, now) %}
{% set dataHref = '' %}
{% if is_granted('view', project) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('project_details', {'id': project.id}) %}
{% endif -%}
class="{{ class }}" data-href="{{ dataHref }}"
{% endmacro %}
{#
To be used like this:
<tr {{ class_activity_row(activity, now) }}>
#}
{% macro class_activity_row(activity, now) %}
{%- if not activity.visible %}warning{% endif -%}
{% endmacro %}
{#
To be used like this:
<tr {{ activity_row_attr(activity, now) }}>
#}
{% macro activity_row_attr(activity, now) %}
{%- set class = _self.class_activity_row(activity, now) %}
{% set dataHref = '' %}
{% if is_granted('view', activity) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('activity_details', {'id': activity.id}) %}
{% endif -%}
class="{{ class }}" data-href="{{ dataHref }}"
{% endmacro %}
{#
To be used like this:
<tr class="{{ class_user_row(user, now) }}">
#}
{% macro class_user_row(user) %}
{%- if not user.enabled %}warning{% endif -%}
{% endmacro %}
{#
To be used like this:
<tr {{ user_row_attr(user) }}>
#}
{% macro user_row_attr(user) %}
{%- set class = _self.class_user_row(user) %}
{% set dataHref = '' %}
{% if is_granted('view', user) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('user_profile', {'username': user.username}) %}
{% endif -%}
class="{{ class }}" data-href="{{ dataHref }}"
{% endmacro %}
{# To be used mainly in <tr class="{{ class_user_row(user, now) }}"> #}
{% macro short_stats_row(stats) %}
<div class="row">
{% set colLength = 12 / stats|length %}
{% for title, value in stats %}
<div class="col-sm-{{ colLength }} border-right">
<div class="description-block">
<h5 class="description-header">{{ value }}</h5>
<span class="description-text">{{ title }}</span>
</div>
</div>
{% endfor %}
</div>
{% endmacro %}

View File

@@ -4,10 +4,10 @@
{{ widgets.page_actions(event.actions) }}
{% endmacro %}
{% macro project(project, view) %}
{% macro project(project, view, isTable) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'project', view, {'project': project}) %}
{% if view == 'index' or view == 'custom' %}
{% if view == 'index' or view == 'custom' or isTable is not null %}
{{ widgets.table_actions(event.actions) }}
{% else %}
{{ widgets.page_actions(event.actions) }}

View File

@@ -3,7 +3,7 @@
{% import "project/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}
{% block page_actions %}{{ actions.project(project, 'details') }}{% endblock %}
{% block page_actions %}{{ actions.project(project, 'project_details') }}{% endblock %}
{% block main %}
{% set can_edit = is_granted('edit', project) %}
@@ -36,7 +36,7 @@
</td>
</tr>
{% endif %}
<tr class="{{ widgets.class_customer_row(project.customer, now) }}">
<tr {{ widgets.customer_row_attr(project.customer, now) }}>
<th>{{ 'label.customer'|trans }}</th>
<td>
{{ widgets.label_customer(project.customer) }}
@@ -99,7 +99,7 @@
{{ render(controller('App\\Controller\\ProjectController::activitiesAction', {'project': project.id, 'page': 1})) }}
{% if stats is not null %}
{{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }}
{{ include('embeds/budgets.html.twig', {'entity': project, 'stats': stats, 'currency': project.customer.currency}) }}
{% endif %}
{% if can_edit %}

View File

@@ -3,7 +3,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'menu.admin_activity'|trans }}{% endblock %}
{% block box_attributes %}
id="activity_list_box" data-href="{{ path('project_activities', {'id': project.id}) }}" data-reload="kimai.activityUpdate"
id="activity_list_box" data-href="{{ path('project_activities', {'id': project.id}) }}" data-reload="kimai.activityUpdate kimai.activityDelete"
{% endblock %}
{% block box_tools %}
{% if project.visible and project.customer.visible and is_granted('create_activity') %}
@@ -31,12 +31,12 @@
</thead>
<tbody>
{% for activity in activities %}
<tr class="{{ widgets.class_activity_row(activity, now) }}">
<tr {{ widgets.activity_row_attr(activity, now) }}>
<td>{{ widgets.label_activity(activity) }}</td>
<td class="hidden-xs">{{ activity.comment|comment1line(not app.user.smallLayout) }}</td>
<td class="w-min text-center">{{ widgets.label_visible(activity.visible) }}</td>
<td class="w-min text-center">{{ widgets.badge_team_access(activity.teams) }}</td>
<td class="actions">{{ actions.activity(activity, 'custom') }}</td>
<td class="actions">{{ actions.activity(activity, 'project_details', true) }}</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,24 +0,0 @@
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/progressbar.html.twig" as progress %}
{% block box_title %}{{ 'label.budget'|trans }}{% endblock %}
{% block box_attributes %}id="budget_box"{% endblock %}
{% block box_body %}
{% set params = {
'%project%': project.name,
'%customer%': project.customer.name,
'%records%': stats.recordAmount,
'%activities%': stats.activityAmount,
'%duration%': stats.recordDuration|duration
} %}
{% set currency = project.customer.currency %}
<p>
{{ 'admin_project.short_stats'|trans(params) }}
{{ 'label.rate_internal'|trans }}: {{ stats.recordInternalRate|money(currency) }}.
</p>
{{ progress.progressbar(project.budget, stats.recordRate, 'label.budget'|trans, stats.recordRate|money(currency) ~ ' / ' ~ project.budget|money(currency) ) }}
{{ progress.progressbar(project.timeBudget, stats.recordDuration, 'label.timeBudget'|trans, stats.recordDuration|duration ~ ' / ' ~ project.timeBudget|duration ) }}
{% endblock %}
{% endembed %}

View File

@@ -41,16 +41,10 @@
{% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.projectUpdate kimai.projectTeamUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectTeamUpdate'}) }}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectDelete kimai.projectTeamUpdate'}) }}
{% for entry in entries %}
{% set class = widgets.class_project_row(entry, now) %}
{% set dataHref = '' %}
{% if is_granted('view', entry) %}
{% set class = class ~ ' alternative-link open-edit' %}
{% set dataHref = path('project_details', {'id': entry.id}) %}
{% endif %}
<tr class="{{ class }}" data-href="{{ dataHref }}">
<tr {{ widgets.project_row_attr(entry, now) }}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>

View File

@@ -1,10 +1,11 @@
{% extends 'reporting/layout.html.twig' %}
{% import "macros/datatables.html.twig" as tables %}
{% block report_title %}{{ 'report_project_view'|trans({}, 'reporting') }}{% endblock %}
{% block report_title %}{{ (title|default('report_project_view'))|trans({}, 'reporting') }}{% endblock %}
{% set columns = {
'name': {'class': 'alwaysVisible'},
'lastRecord': {'class': 'hidden text-center hw-min', 'title': 'label.last_record'|trans},
'today': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationToday'|trans},
'week': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationWeek'|trans},
'month': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationMonth'|trans},
@@ -17,7 +18,7 @@
'comment': {'class': 'hidden-md hidden-sm hidden-xs hidden', 'title': 'label.comment'|trans},
'actions': {'class': 'actions alwaysVisible'},
} %}
{% set tableName = 'project_view_reporting' %}
{% set tableName = tableName|default('project_view_reporting') %}
{% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }}
@@ -32,7 +33,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "project/actions.html.twig" as projectActions %}
{% block box_body_class %}project-view-reporting-box {% if hasData %}no-padding{% endif %}{% endblock %}
{% block box_body_class %}{{ tableName }}-box {% if hasData %}no-padding{% endif %}{% endblock %}
{% block box_before %}
{{ form_start(form, {'attr': {'class': 'form-inline form-reporting', 'id': 'project-view-form'}}) }}
{% endblock %}
@@ -40,7 +41,7 @@
{{ form_end(form) }}
{% endblock %}
{% block box_tools %}
{{ widgets.action_button('visibility', {'modal': '#modal_project_view_reporting', 'class': 'btn-sm'}) }}
{{ widgets.action_button('visibility', {'modal': ('#modal_' ~ tableName), 'class': 'btn-sm'}) }}
{% endblock %}
{% block box_title %}
{{ form_widget(form) }}
@@ -51,57 +52,64 @@
{% else %}
{{ tables.datatable_header(tableName, columns, null, {'bordered': true, 'boxClass': ''}) }}
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
<tr class="summary">
<td colspan="11">{{ widgets.label_customer(mapping.customer) }}</td>
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
<tr class="summary">
<td colspan="{{ columns|length }}">{{ widgets.label_customer(mapping.customer) }}</td>
</tr>
{% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %}
{% set project = entry.project %}
{% set currency = project.customer.currency %}
{% if is_granted('budget', project) %}
<tr {{ widgets.project_row_attr(project, now) }}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_project(project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'lastRecord') }}">
{% if entry.lastRecord is not null %}
{{ entry.lastRecord|date_short }}
{% else %}
&ndash;
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'today') }}">{{ entry.durationDay|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'week') }}">{{ entry.durationWeek|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'month') }}">{{ entry.durationMonth|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'durationTotal') }}">{{ entry.durationTotal|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if project.timeBudget > 0 %}
{{ progress.progressbar(project.timeBudget, entry.billableDuration|default(0), entry.billableDuration|duration ~ ' / ' ~ project.timeBudget|duration, '') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if project.budget > 0 %}
{{ progress.progressbar(project.budget, entry.billableRate|default(0), entry.billableRate|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}">
{% if is_granted('create_export') %}
<a href="{{ path('export', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': '', 'preview': true}) }}">
{{ entry.notExportedDuration|duration }}
</a>
{% else %}
{{ entry.notExportedDuration|duration }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateMoney') }}">
{% if is_granted('view_invoice') %}
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
{{ entry.notBilledRate|money(currency) }}
</a>
{% else %}
{{ entry.notBilledRate|money(currency) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'projectEnd') }}">{% if project.end is not null %}{{ project.end|date_short }}{% endif %}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ project.comment }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
{{ projectActions.project(project, 'custom') }}
</td>
</tr>
{% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %}
{% set project = entry.project %}
{% set currency = project.customer.currency %}
{% if is_granted('budget', project) %}
<tr{% if is_granted('view', project) %} class="alternative-link open-edit" data-href="{{ path('project_details', {'id': project.id}) }}"{% endif %}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_project(project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'today') }}">{{ entry.durationDay|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'week') }}">{{ entry.durationWeek|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'month') }}">{{ entry.durationMonth|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'durationTotal') }}">{{ entry.durationTotal|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if project.timeBudget > 0 %}
{{ progress.progressbar(project.timeBudget, entry.durationTotal|default(0), entry.durationTotal|duration ~ ' / ' ~ project.timeBudget|duration, '') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if project.budget > 0 %}
{{ progress.progressbar(project.budget, entry.rateTotal|default(0), entry.rateTotal|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}">
{% if is_granted('create_export') %}
<a href="{{ path('export', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': '', 'preview': true}) }}">
{{ entry.notExportedDuration|duration }}
</a>
{% else %}
{{ entry.notExportedDuration|duration }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateMoney') }}">
{% if is_granted('view_invoice') %}
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
{{ entry.notBilledRate|money(currency) }}
</a>
{% else %}
{{ entry.notBilledRate|money(currency) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'projectEnd') }}">{% if project.end is not null %}{{ project.end|date_short }}{% endif %}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ project.comment }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
{{ projectActions.project(project, 'custom') }}
</td>
</tr>
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endblock %}

View File

@@ -88,6 +88,26 @@
</div>
</div>
{% endif %}
{% if form.metaFields is defined and form.metaFields is not empty %}
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
{{ form_row(meta) }}
{% endfor %}
{% endif %}
{% if form.exported is defined or form.billable is defined %}
{% set ebLength = (form.exported is defined and form.billable is defined) ? 6 : 12 %}
<div class="row">
{% if form.exported is defined %}
<div class="col-md-{{ ebLength }}">
{{ form_row(form.exported) }}
</div>
{% endif %}
{% if form.billable is defined %}
<div class="col-md-{{ ebLength }}">
{{ form_row(form.billable) }}
</div>
{% endif %}
</div>
{% endif %}
{{ form_widget(form) }}
{% endblock %}
{% block form_after %}

View File

@@ -30,13 +30,6 @@
'project': {'class': 'hidden-xs hidden-sm hidden-md'},
'activity': {'class': 'hidden-xs hidden-sm'},
'description': {'class': 'hidden-xs hidden-sm'},
}) %}
{% if canSeeUsername %}
{% set columns = columns|merge({
'username': {'class': 'hidden-xs', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'tags': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
}) %}
{% for field in metaColumns %}
@@ -44,7 +37,13 @@
('mf_' ~ field.name): {'title': field.label|trans, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
}) %}
{% endfor %}
{% if canSeeUsername %}
{% set columns = columns|merge({
'username': {'class': 'hidden-xs', 'orderBy': false},
}) %}
{% endif %}
{% set columns = columns|merge({
'billable': {'class': 'text-center hidden w-min', 'orderBy': false},
'exported': {'class': 'text-center hidden w-min', 'orderBy': false},
'actions': {'class': 'actions alwaysVisible'},
}) %}
@@ -60,7 +59,7 @@
{% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.timesheetUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.timesheetUpdate'}) }}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.timesheetUpdate kimai.timesheetDelete'}) }}
{% set checkOverlappingDesc = false %}
{% set checkOverlappingAsc = false %}
@@ -150,19 +149,23 @@
{{ entry.description|nl2br }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
{% if canSeeUsername %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">
{{ widgets.label_user(entry.user) }}
</td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
{% for field in metaColumns %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
{{ tables.datatable_meta_column(entry, field) }}
</td>
{% endfor %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'billable') }}">
{{ widgets.label_boolean(entry.billable) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}">
{{ widgets.label_boolean(entry.exported) }}
</td>
@@ -213,13 +216,15 @@
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></td>
{% if canSeeUsername %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}"></td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}"></td>
{% for field in metaColumns %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"></td>
{% endfor %}
{% if canSeeUsername %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}"></td>
{% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'billable') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}"></td>
<td class="actions"></td>
</tr>
{% endmacro %}

View File

@@ -42,7 +42,7 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}
{% for entry in entries %}
<tr{% if is_granted('view', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile', {'username': entry.username}) }}"{% endif %}>
<tr {{ widgets.user_row_attr(entry) }}>
<td class="{{ tables.data_table_column_class(tableName, columns, 'avatar') }}">{{ widgets.user_avatar(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'alias') }}">{{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>

View File

@@ -53,8 +53,20 @@
tooltips: {
callbacks: {
label: function(tooltipItem, data) {
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
}
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
return tooltipData['duration'];
},
afterTitle: function(tooltipItems, data) {
return ' ';
},
beforeFooter: function(tooltipItems, data) {
return ' ';
},
footer: function(tooltipItems, data) {
var tooltipItem = tooltipItems[0];
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
return '{{ 'label.billable'|trans }}: ' + tooltipData['billable'];
},
}
}
{%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
@@ -95,8 +107,9 @@
],
realData: [
{% for month in yearStat.months %}
'{{ month.totalDuration|duration }}'
{% if not loop.last %},{% endif %}
{% set realDayData = {duration: month.totalDuration|duration, billable: month.billableDuration|duration} %}
{{ realDayData|json_encode|raw }}
{% if not loop.last %},{% endif %}
{% endfor %}
],
monthData: [

View File

@@ -55,7 +55,7 @@
{%- for entry in day.details -%}
{% set loopId = (entry.project.id ~ '_' ~ entry.activity.id) %}
{%- if loopId == activityId -%}
{% set realDayData = {duration: entry.duration|duration, project: entry.project.name, customer: entry.project.customer.name, activity: entry.activity.name, total: day.totalDuration|duration} %}
{% set realDayData = {duration: entry.duration|duration, billable: day.totalDurationBillable|duration, project: entry.project.name, customer: entry.project.customer.name, activity: entry.activity.name, total: day.totalDuration|duration} %}
{%- endif -%}
{%- endfor -%}
{%- if realDayData is not null -%}
@@ -117,7 +117,7 @@
footer: function(tooltipItems, data) {
var tooltipItem = tooltipItems[0];
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
return '{{ 'stats.durationTotal'|trans }}: ' + tooltipData.total;
return '{{ 'stats.durationTotal'|trans }}: ' + tooltipData.total + '\n{{ 'label.billable'|trans }}: ' + tooltipData.billable;
},
beforeFooter: function(tooltipItems, data) {
return ' ';

View File

@@ -35,10 +35,10 @@
<td style="width:50%">
{% if project.timeBudget is not empty and project.timeBudget > 0 %}
{% set budgetLeft = project.timeBudget - stats.recordDuration %}
{{ progress.progressbar_small(project.timeBudget, stats.recordDuration, budgetLeft, budgetLeft|duration) }}
{{ progress.progressbar_small(project.timeBudget, stats.durationBillable, budgetLeft, budgetLeft|duration) }}
{% elseif project.budget is not empty and project.budget > 0 %}
{% set budgetLeft = project.budget - stats.recordRate %}
{{ progress.progressbar_small(project.budget, stats.recordRate, budgetLeft, budgetLeft|money(project.customer.currency)) }}
{{ progress.progressbar_small(project.budget, stats.rateBillable, budgetLeft, budgetLeft|money(project.customer.currency)) }}
{% endif %}
</td>
</tr>

View File

@@ -483,6 +483,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
'internalRate' => 'float',
'exported' => 'bool',
'billable' => 'bool',
'fixedRate' => '@float',
'hourlyRate' => '@float',
// TODO new fields: billable, category
@@ -503,6 +504,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
'internalRate' => 'float',
'exported' => 'bool',
'billable' => 'bool',
'fixedRate' => '@float',
'hourlyRate' => '@float',
// TODO new fields: billable, category
@@ -522,6 +524,8 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'user' => 'int',
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
'internalRate' => 'float',
'exported' => 'bool',
'billable' => 'bool',
];
case 'TimesheetCollectionFull':
@@ -538,6 +542,8 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
'user' => 'int',
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
'internalRate' => 'float',
'exported' => 'bool',
'billable' => 'bool',
];
default:

View File

@@ -139,6 +139,8 @@ class ActivityControllerTest extends ControllerBaseTest
$node = $client->getCrawler()->filter('div.box#activity_details_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#time_budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_rates_box');

View File

@@ -199,12 +199,12 @@ abstract class ControllerBaseTest extends WebTestCase
/**
* @param HttpKernelBrowser $client
* @param string $id
* @param string $class
* @param int $count
*/
protected function assertDataTableRowCount(HttpKernelBrowser $client, string $id, int $count)
protected function assertDataTableRowCount(HttpKernelBrowser $client, string $class, int $count)
{
$node = $client->getCrawler()->filter('section.content div.' . $id . ' table.dataTable tbody tr:not(.summary)');
$node = $client->getCrawler()->filter('section.content div.' . $class . ' table.dataTable tbody tr:not(.summary)');
self::assertEquals($count, $node->count());
}

View File

@@ -114,6 +114,8 @@ class CustomerControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#project_list_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#time_budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box');

View File

@@ -34,7 +34,7 @@ class ExportControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/?preview=');
$this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client);
@@ -83,13 +83,13 @@ class ExportControllerTest extends ControllerBaseTest
$this->importFixture($fixture);
$em->flush();
$this->request($client, '/export/?preview=');
$this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed
$this->assertHasDataTable($client);
// +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 23);
$this->assertDataTableRowCount($client, 'datatable_export', 22);
// assert export type buttons are available
$expected = [
@@ -113,7 +113,6 @@ class ExportControllerTest extends ControllerBaseTest
public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$begin = new \DateTime('first day of this month');
$user = $this->getUserByRole(User::ROLE_USER);
@@ -127,10 +126,10 @@ class ExportControllerTest extends ControllerBaseTest
;
$this->importFixture($fixture);
$this->request($client, '/export/?preview=');
$this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed
// make sure existing records are not displayed
$this->assertHasNoEntriesWithFilter($client);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
@@ -143,13 +142,13 @@ class ExportControllerTest extends ControllerBaseTest
;
$this->importFixture($fixture);
$this->request($client, '/export/?preview=');
$this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed
$this->assertHasDataTable($client);
// +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 3);
$this->assertDataTableRowCount($client, 'datatable_export', 2);
// assert export type buttons are available
$expected = [

View File

@@ -148,6 +148,8 @@ class ProjectControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_list_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#time_budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box');

View File

@@ -0,0 +1,60 @@
<?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\Tests\Controller\Reporting;
use App\Entity\User;
use App\Tests\Controller\ControllerBaseTest;
use App\Tests\DataFixtures\ActivityFixtures;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\ProjectFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
/**
* @group integration
*/
class InactiveProjectControllerTest extends ControllerBaseTest
{
public function testReportIsSecure()
{
$this->assertUrlIsSecured('/reporting/project_inactive');
}
public function testReport()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$customers = new CustomerFixtures();
$customers->setIsVisible(true);
$customers->setAmount(1);
$customers = $this->importFixture($customers);
$projects = new ProjectFixtures();
$projects->setCustomers($customers);
$projects->setAmount(2);
$projects->setIsVisible(true);
$this->importFixture($projects);
$activities = new ActivityFixtures();
$activities->setAmount(5);
$activities->setIsGlobal(true);
$activities = $this->importFixture($activities);
$timesheets = new TimesheetFixtures();
$timesheets->setAmount(50);
$timesheets->setActivities($activities);
$timesheets->setUser($this->getUserByRole(User::ROLE_TEAMLEAD));
$this->importFixture($timesheets);
$this->assertAccessIsGranted($client, '/reporting/project_inactive');
self::assertStringContainsString('<div class="box-body inactive_project_reporting-box', $client->getResponse()->getContent());
$rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
self::assertGreaterThan(0, $rows->count());
}
}

View File

@@ -21,12 +21,12 @@ use App\Tests\DataFixtures\TimesheetFixtures;
*/
class ProjectViewControllerTest extends ControllerBaseTest
{
public function testProjectViewIsSecure()
public function testReportIsSecure()
{
$this->assertUrlIsSecured('/reporting/project_view');
}
public function testProjectViewReport()
public function testReport()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -39,7 +39,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
$projects->setCustomers($customers);
$projects->setAmount(2);
$projects->setIsVisible(true);
$projects = $this->importFixture($projects);
$this->importFixture($projects);
$activities = new ActivityFixtures();
$activities->setAmount(5);
@@ -53,7 +53,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
$this->importFixture($timesheets);
$this->assertAccessIsGranted($client, '/reporting/project_view');
self::assertStringContainsString('<div class="box-body project-view-reporting-box', $client->getResponse()->getContent());
self::assertStringContainsString('<div class="box-body project_view_reporting-box', $client->getResponse()->getContent());
$rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
self::assertGreaterThan(0, $rows->count());
}

View File

@@ -38,6 +38,7 @@ class UserTest extends TestCase
self::assertFalse($user->hasTeamAssignment());
self::assertFalse($user->canSeeAllData());
self::assertFalse($user->isSmallLayout());
self::assertFalse($user->isExportDecimal());
$user->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
self::assertEquals('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', $user->getAvatar());
@@ -114,6 +115,9 @@ class UserTest extends TestCase
self::assertTrue($user->isSmallLayout());
$user->setPreferenceValue('theme.layout', '12345');
self::assertFalse($user->isSmallLayout());
$user->setPreferenceValue('timesheet.export_decimal', true);
self::assertTrue($user->isExportDecimal());
}
public function testDisplayName()

View File

@@ -91,25 +91,26 @@ class CsvRendererTest extends AbstractRendererTest
10 => 'activity description',
11 => '',
12 => '',
13 => 'foo,bar',
14 => '',
15 => ' EUR 84.00 ',
16 => 'meta-bar',
17 => 'meta-bar2',
18 => 'customer-bar',
19 => '',
20 => 'project-foo2',
21 => 'activity-bar',
22 => 'timesheet',
23 => 'work',
24 => 'A-0123456789',
25 => 'DE-9876543210',
26 => 'ORDER-123',
13 => '',
14 => 'foo,bar',
15 => '',
16 => ' EUR 84.00 ',
17 => 'meta-bar',
18 => 'meta-bar2',
19 => 'customer-bar',
20 => '',
21 => 'project-foo2',
22 => 'activity-bar',
23 => 'timesheet',
24 => 'work',
25 => 'A-0123456789',
26 => 'DE-9876543210',
27 => 'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][13]);
self::assertEquals('foo', $all[4][14]);
}
}

View File

@@ -88,25 +88,26 @@ class CsvRendererTest extends AbstractRendererTest
10 => 'activity description',
11 => '',
12 => '',
13 => 'foo,bar',
14 => '',
15 => ' EUR 84.00 ',
16 => 'meta-bar',
17 => 'meta-bar2',
18 => 'customer-bar',
19 => '',
20 => 'project-foo2',
21 => 'activity-bar',
22 => 'timesheet',
23 => 'work',
24 => 'A-0123456789',
25 => 'DE-9876543210',
26 => 'ORDER-123',
13 => '',
14 => 'foo,bar',
15 => '',
16 => ' EUR 84.00 ',
17 => 'meta-bar',
18 => 'meta-bar2',
19 => 'customer-bar',
20 => '',
21 => 'project-foo2',
22 => 'activity-bar',
23 => 'timesheet',
24 => 'work',
25 => 'A-0123456789',
26 => 'DE-9876543210',
27 => 'ORDER-123',
];
self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][13]);
self::assertEquals('foo', $all[4][14]);
}
}

View File

@@ -23,10 +23,11 @@ class DayTest extends TestCase
$date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956);
$this->assertSame($date, $sut->getDay());
$this->assertEquals([], $sut->getDetails());
$this->assertEquals(12340, $sut->getTotalDuration());
$this->assertEquals(197.25956, $sut->getTotalRate());
self::assertSame($date, $sut->getDay());
self::assertEquals([], $sut->getDetails());
self::assertSame(12340, $sut->getTotalDuration());
self::assertSame(197.25956, $sut->getTotalRate());
self::assertSame(0, $sut->getTotalDurationBillable());
}
public function testAllowedMonths()
@@ -36,9 +37,11 @@ class DayTest extends TestCase
$sut->setTotalDuration(999);
$sut->setTotalRate(0.123456789);
$sut->setTotalDurationBillable(12345);
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
self::assertSame(999, $sut->getTotalDuration());
self::assertSame(0.123456789, $sut->getTotalRate());
self::assertSame(12345, $sut->getTotalDurationBillable());
}
public function testSetDetails()
@@ -47,6 +50,6 @@ class DayTest extends TestCase
$sut->setDetails(['foo' => ['bar' => '1212e'], 'hello' => 'world']);
$this->assertEquals(['foo' => ['bar' => '1212e'], 'hello' => 'world'], $sut->getDetails());
self::assertEquals(['foo' => ['bar' => '1212e'], 'hello' => 'world'], $sut->getDetails());
}
}

View File

@@ -22,9 +22,11 @@ class MonthTest extends TestCase
public function testDefaultValues()
{
$sut = new Month('01');
$this->assertEquals('01', $sut->getMonth());
$this->assertEquals(0, $sut->getTotalDuration());
$this->assertEquals(0, $sut->getTotalRate());
self::assertSame('01', $sut->getMonth());
self::assertSame(0, $sut->getTotalDuration());
self::assertSame(0.0, $sut->getTotalRate());
self::assertSame(0, $sut->getBillableDuration());
self::assertSame(0.0, $sut->getBillableRate());
}
public function testAllowedMonths()
@@ -35,7 +37,7 @@ class MonthTest extends TestCase
for ($i = 10; $i < 13; $i++) {
new Month((string) $i);
}
$this->assertTrue(true);
self::assertTrue(true);
}
public function testInvalidMonths()
@@ -47,8 +49,8 @@ class MonthTest extends TestCase
} catch (Exception $e) {
$ex = $e;
}
$this->assertInstanceOf(InvalidArgumentException::class, $ex);
$this->assertEquals(
self::assertInstanceOf(InvalidArgumentException::class, $ex);
self::assertEquals(
'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".',
$ex->getMessage()
);
@@ -60,8 +62,12 @@ class MonthTest extends TestCase
$sut = new Month('01');
$sut->setTotalDuration(999);
$sut->setTotalRate(0.123456789);
$sut->setBillableDuration(123456);
$sut->setBillableRate(123.456789);
$this->assertEquals(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
self::assertSame(999, $sut->getTotalDuration());
self::assertSame(0.123456789, $sut->getTotalRate());
self::assertSame(123456, $sut->getBillableDuration());
self::assertSame(123.456789, $sut->getBillableRate());
}
}

View File

@@ -21,10 +21,10 @@ class YearTest extends TestCase
public function testDefaultValues()
{
$sut = new Year('1999');
$this->assertNull($sut->getMonth(1));
$this->assertEmpty($sut->getMonths());
$this->assertIsArray($sut->getMonths());
$this->assertEquals('1999', $sut->getYear());
self::assertNull($sut->getMonth(1));
self::assertEmpty($sut->getMonths());
self::assertIsArray($sut->getMonths());
self::assertEquals('1999', $sut->getYear());
}
public function testSetter()
@@ -34,15 +34,15 @@ class YearTest extends TestCase
$sut->setMonth(new Month('01'));
$sut->setMonth(new Month('02'));
$sut->setMonth(new Month('03'));
$this->assertEquals(3, \count($sut->getMonths()));
self::assertEquals(3, \count($sut->getMonths()));
$sut->setMonth(new Month('01'));
$this->assertEquals(3, \count($sut->getMonths()));
self::assertEquals(3, \count($sut->getMonths()));
$this->assertInstanceOf(Month::class, $sut->getMonth(1));
$this->assertInstanceOf(Month::class, $sut->getMonth(2));
$this->assertInstanceOf(Month::class, $sut->getMonth(3));
$this->assertNull($sut->getMonth(4));
self::assertInstanceOf(Month::class, $sut->getMonth(1));
self::assertInstanceOf(Month::class, $sut->getMonth(2));
self::assertInstanceOf(Month::class, $sut->getMonth(3));
self::assertNull($sut->getMonth(4));
}
}

View File

@@ -20,10 +20,13 @@ class TimesheetCountedStatisticTest extends TestCase
public function testDefaultValues()
{
$sut = new TimesheetCountedStatistic();
self::assertEquals(0.0, $sut->getRecordRate());
self::assertEquals(0, $sut->getRecordDuration());
self::assertEquals(0, $sut->getRecordAmount());
self::assertEquals(0.0, $sut->getRecordInternalRate());
self::assertSame(0.0, $sut->getRecordRate());
self::assertSame(0, $sut->getRecordDuration());
self::assertSame(0, $sut->getRecordAmount());
self::assertSame(0.0, $sut->getRecordInternalRate());
self::assertSame(0, $sut->getDurationBillable());
self::assertSame(0.0, $sut->getRateBillable());
self::assertSame(0, $sut->getRecordAmountBillable());
}
public function testSetter()
@@ -34,9 +37,17 @@ class TimesheetCountedStatisticTest extends TestCase
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordAmount(5));
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09));
self::assertEquals(23.97, $sut->getRecordRate());
self::assertEquals(21, $sut->getRecordDuration());
self::assertEquals(5, $sut->getRecordAmount());
self::assertEquals(99.09, $sut->getRecordInternalRate());
self::assertSame(23.97, $sut->getRecordRate());
self::assertSame(21, $sut->getRecordDuration());
self::assertSame(5, $sut->getRecordAmount());
self::assertSame(99.09, $sut->getRecordInternalRate());
$sut->setRateBillable(123.456);
$sut->setDurationBillable(1234);
$sut->setRecordAmountBillable(4321);
self::assertSame(123.456, $sut->getRateBillable());
self::assertSame(1234, $sut->getDurationBillable());
self::assertSame(4321, $sut->getRecordAmountBillable());
}
}

View File

@@ -0,0 +1,42 @@
<?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\Tests\Reporting\ProjectInactive;
use App\Entity\User;
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Reporting\ProjectInactive\ProjectInactiveQuery
*/
class ProjectInactiveQueryTest extends TestCase
{
public function testDefaults()
{
$user = new User();
$date = new \DateTime();
$sut = new ProjectInactiveQuery($date, $user);
self::assertEquals($date, $sut->getLastChange());
self::assertSame($user, $sut->getUser());
}
public function testSetterGetter()
{
$user = new User();
$date = new \DateTime();
$sut = new ProjectInactiveQuery($date, $user);
$date1 = new \DateTime('2020-01-02 19:23:34');
$sut->setLastChange($date1);
self::assertEquals($date1, $sut->getLastChange());
}
}

View File

@@ -24,19 +24,28 @@ class ProjectViewModelTest extends TestCase
$sut = new ProjectViewModel($project);
self::assertSame($project, $sut->getProject());
self::assertEquals(0, $sut->getDurationDay());
self::assertEquals(0, $sut->getDurationMonth());
self::assertEquals(0, $sut->getDurationTotal());
self::assertEquals(0, $sut->getDurationWeek());
self::assertEquals(0, $sut->getNotExportedDuration());
self::assertEquals(0, $sut->getNotExportedRate());
self::assertEquals(0, $sut->getRateTotal());
self::assertSame(0, $sut->getDurationDay());
self::assertSame(0, $sut->getDurationMonth());
self::assertSame(0, $sut->getDurationTotal());
self::assertSame(0, $sut->getDurationWeek());
self::assertSame(0, $sut->getNotExportedDuration());
self::assertSame(0.0, $sut->getNotExportedRate());
self::assertSame(0.0, $sut->getRateTotal());
self::assertNull($sut->getLastRecord());
self::assertSame(0, $sut->getTimesheetCounter());
self::assertNull($sut->getLastRecord());
self::assertSame(0.0, $sut->getBillableRate());
self::assertSame(0, $sut->getBillableDuration());
self::assertSame(0, $sut->getNotBilledDuration());
self::assertSame(0.0, $sut->getNotBilledRate());
}
public function testSetterGetter()
{
$sut = new ProjectViewModel(new Project());
$date = new \DateTime();
$sut->setDurationDay(123456789);
$sut->setDurationMonth(23456789);
$sut->setDurationTotal(3456789);
@@ -44,13 +53,30 @@ class ProjectViewModelTest extends TestCase
$sut->setNotExportedDuration(56789);
$sut->setNotExportedRate(6789);
$sut->setRateTotal(789);
$sut->setLastRecord($date);
self::assertEquals(123456789, $sut->getDurationDay());
self::assertEquals(23456789, $sut->getDurationMonth());
self::assertEquals(3456789, $sut->getDurationTotal());
self::assertEquals(456789, $sut->getDurationWeek());
self::assertEquals(56789, $sut->getNotExportedDuration());
self::assertEquals(6789, $sut->getNotExportedRate());
self::assertEquals(789, $sut->getRateTotal());
self::assertSame(123456789, $sut->getDurationDay());
self::assertSame(23456789, $sut->getDurationMonth());
self::assertSame(3456789, $sut->getDurationTotal());
self::assertSame(456789, $sut->getDurationWeek());
self::assertSame(56789, $sut->getNotExportedDuration());
self::assertSame(6789.0, $sut->getNotExportedRate());
self::assertSame(789.0, $sut->getRateTotal());
self::assertSame($date, $sut->getLastRecord());
$date = new \DateTime();
$sut->setTimesheetCounter(123);
$sut->setLastRecord($date);
$sut->setBillableRate(123.456);
$sut->setBillableDuration(321);
$sut->setNotBilledDuration(9876);
$sut->setNotBilledRate(4705.23);
self::assertSame(123, $sut->getTimesheetCounter());
self::assertSame($date, $sut->getLastRecord());
self::assertSame(123.456, $sut->getBillableRate());
self::assertSame(321, $sut->getBillableDuration());
self::assertSame(9876, $sut->getNotBilledDuration());
self::assertSame(4705.23, $sut->getNotBilledRate());
}
}

View File

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

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Repository\Query;
use App\Entity\Activity;
use App\Entity\Bookmark;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
@@ -71,6 +72,7 @@ class BaseQueryTest extends TestCase
$this->assertOrderBy($sut, $orderBy);
$this->assertOrder($sut, $order);
$this->assertTeams($sut);
$this->assertBookmark($sut);
}
private function getFormBuilder(string $name)
@@ -119,6 +121,16 @@ class BaseQueryTest extends TestCase
self::assertSame($team, $sut->getTeams()[0]);
}
protected function assertBookmark(BaseQuery $sut)
{
$bookmark = new Bookmark();
self::assertNull($sut->getBookmark());
self::assertFalse($sut->hasBookmark());
$sut->setBookmark($bookmark);
self::assertSame($bookmark, $sut->getBookmark());
self::assertTrue($sut->hasBookmark());
}
protected function assertPage(BaseQuery $sut)
{
self::assertEquals(1, $sut->getPage());

View File

@@ -76,4 +76,47 @@ class ThemeEventExtensionTest extends TestCase
$values = $sut->getJavascriptTranslations();
self::assertCount(24, $values);
}
public function getProgressbarColors()
{
yield ['progress-bar-danger', 100, false];
yield ['progress-bar-danger', 91, false];
yield ['progress-bar-warning', 90, false];
yield ['progress-bar-warning', 80, false];
yield ['progress-bar-warning', 71, false];
yield ['progress-bar-success', 70, false];
yield ['progress-bar-success', 60, false];
yield ['progress-bar-success', 51, false];
yield ['progress-bar-primary', 50, false];
yield ['progress-bar-primary', 40, false];
yield ['progress-bar-primary', 31, false];
yield ['progress-bar-info', 30, false];
yield ['progress-bar-info', 20, false];
yield ['progress-bar-info', 10, false];
yield ['progress-bar-info', 0, false];
yield ['progress-bar-primary', 100, true];
yield ['progress-bar-primary', 91, true];
yield ['progress-bar-success', 90, true];
yield ['progress-bar-success', 80, true];
yield ['progress-bar-success', 71, true];
yield ['progress-bar-warning', 70, true];
yield ['progress-bar-warning', 60, true];
yield ['progress-bar-warning', 51, true];
yield ['progress-bar-danger', 50, true];
yield ['progress-bar-danger', 40, true];
yield ['progress-bar-danger', 31, true];
yield ['progress-bar-info', 30, true];
yield ['progress-bar-info', 20, true];
yield ['progress-bar-info', 10, true];
yield ['progress-bar-info', 0, true];
}
/**
* @dataProvider getProgressbarColors
*/
public function testProgressbarClass(string $expected, int $percent, ?bool $reverseColors = false)
{
$sut = $this->getSut(false);
self::assertEquals($expected, $sut->getProgressbarClass($percent, $reverseColors));
}
}

View File

@@ -40,6 +40,7 @@ class RuntimeExtensionsTest extends TestCase
$expected = [
'trigger',
'actions',
'progressbar_color',
'javascript_translations',
'active_timesheets',
'encore_entry_css_source',

View File

@@ -109,9 +109,21 @@ class DailyWorkingTimeChartTest extends TestCase
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) use ($activity, $project) {
return [
['year' => $begin->format('Y'), 'month' => $begin->format('n'), 'day' => $begin->format('j'), 'rate' => 13.75, 'duration' => 1234, 'details' => [
['activity' => $activity, 'project' => $project]
]]
[
'year' => $begin->format('Y'),
'month' => $begin->format('n'),
'day' => $begin->format('j'),
'rate' => 13.75,
'duration' => 1234,
'billable' => 1234,
'details' => [
[
'activity' => $activity,
'project' => $project,
'billable' => 1234,
]
]
]
];
});

View File

@@ -36,9 +36,9 @@
<trans-unit id="admin_entity.delete_confirm">
<source>admin_entity.delete_confirm</source>
<target>
Diese Daten werden ebenfalls mit gelöscht!
Alternativ können Sie einen Eintrag auswählen, auf den alle existierenden Daten umgebucht werden:
</target>
Diese Daten werden ebenfalls mit gelöscht!
Alternativ können Sie einen Eintrag auswählen, auf den alle existierenden Daten umgebucht werden:
</target>
</trans-unit>
<trans-unit id="delete.not_in_use">
<source>delete.not_in_use</source>
@@ -1190,6 +1190,14 @@
<source>label.not_invoiced</source>
<target>Nicht abgerechnet</target>
</trans-unit>
<trans-unit id="label.last_record_before">
<source>label.last_record_before</source>
<target>Keine Zeitbuchung mehr seit</target>
</trans-unit>
<trans-unit id="label.last_record">
<source>label.last_record</source>
<target>Letzter Eintrag</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1209,6 +1209,14 @@
<source>label.not_invoiced</source>
<target>Not billed</target>
</trans-unit>
<trans-unit id="label.last_record_before">
<source>label.last_record_before</source>
<target>No time booking since</target>
</trans-unit>
<trans-unit id="label.last_record">
<source>label.last_record</source>
<target>Last entry</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -22,6 +22,10 @@
<source>report_project_view</source>
<target>Projektübersicht</target>
</trans-unit>
<trans-unit id="report_inactive_project">
<source>report_inactive_project</source>
<target>Inaktive Projekte</target>
</trans-unit>
<trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source>
<target>Initialer Bericht</target>

View File

@@ -22,6 +22,10 @@
<source>report_project_view</source>
<target>Project overview</target>
</trans-unit>
<trans-unit id="report_inactive_project">
<source>report_inactive_project</source>
<target>Inactive projects</target>
</trans-unit>
<trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source>
<target>Initial report</target>