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(); }; const handle = function() { self.reloadActiveRecords(); };
document.addEventListener('kimai.timesheetUpdate', handle); document.addEventListener('kimai.timesheetUpdate', handle);
document.addEventListener('kimai.timesheetDelete', handle);
document.addEventListener('kimai.activityUpdate', handle); document.addEventListener('kimai.activityUpdate', handle);
document.addEventListener('kimai.activityDelete', handle);
document.addEventListener('kimai.projectUpdate', handle); document.addEventListener('kimai.projectUpdate', handle);
document.addEventListener('kimai.projectDelete', handle);
document.addEventListener('kimai.customerUpdate', handle); document.addEventListener('kimai.customerUpdate', handle);
document.addEventListener('kimai.customerDelete', handle);
} }
emptyList() { emptyList() {

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -3,7 +3,7 @@
"build/1.32489d92.js": "build/1.32489d92.js", "build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js", "build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"build/app.css": "build/app.bffbc2eb.css", "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.css": "build/calendar.1408f57e.css",
"build/calendar.js": "build/calendar.070aab88.js", "build/calendar.js": "build/calendar.070aab88.js",
"build/chart.js": "build/chart.34d60a88.js", "build/chart.js": "build/chart.34d60a88.js",

View File

@@ -304,7 +304,9 @@ final class KimaiImporterCommand extends Command
$validationMessages = []; $validationMessages = [];
try { try {
$usedEmails = []; $usedEmails = [];
$userIds = [];
foreach ($users as $oldUser) { foreach ($users as $oldUser) {
$userIds[] = $oldUser['userID'];
if (empty($oldUser['mail'])) { if (empty($oldUser['mail'])) {
$validationMessages[] = sprintf('User "%s" with ID %s has no email', $oldUser['name'], $oldUser['userID']); $validationMessages[] = sprintf('User "%s" with ID %s has no email', $oldUser['name'], $oldUser['userID']);
continue; 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']); $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) { } catch (Exception $ex) {
$validationMessages[] = $ex->getMessage(); $validationMessages[] = $ex->getMessage();
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -420,7 +420,7 @@ final class ProjectController extends AbstractController
$deleteForm = $this->createFormBuilder(null, [ $deleteForm = $this->createFormBuilder(null, [
'attr' => [ 'attr' => [
'data-form-event' => 'kimai.projectUpdate kimai.projectDelete', 'data-form-event' => 'kimai.projectDelete',
'data-msg-success' => 'action.delete.success', 'data-msg-success' => 'action.delete.success',
'data-msg-error' => 'action.delete.error', '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; namespace App\Controller\Reporting;
use App\Controller\AbstractController; use App\Controller\AbstractController;
use App\Reporting\ProjectStatisticService;
use App\Reporting\ProjectView\ProjectViewForm; use App\Reporting\ProjectView\ProjectViewForm;
use App\Reporting\ProjectView\ProjectViewQuery; use App\Reporting\ProjectView\ProjectViewQuery;
use App\Reporting\ProjectView\ProjectViewService;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route; 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"}) * @Route(path="/reporting/project_view", name="report_project_view", methods={"GET","POST"})
* @Security("is_granted('view_reporting') and is_granted('budget_project')") * @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()); $dateFactory = $this->getDateTimeFactory();
$user = $this->getUser();
$form = $this->createForm(ProjectViewForm::class, $query, [
'action' => $this->generateUrl('report_project_view')
]);
$query = new ProjectViewQuery($dateFactory->createDateTime(), $user);
$form = $this->createForm(ProjectViewForm::class, $query);
$form->submit($request->query->all(), false); $form->submit($request->query->all(), false);
$entries = $service->getProjectView($query); $projects = $service->findProjectsForView($query);
$entries = $service->getProjectView($user, $projects, $query->getToday());
$byCustomer = []; $byCustomer = [];
foreach ($entries as $entry) { foreach ($entries as $entry) {
@@ -47,6 +47,9 @@ final class ProjectViewController extends AbstractController
return $this->render('reporting/project_view.html.twig', [ return $this->render('reporting/project_view.html.twig', [
'entries' => $byCustomer, 'entries' => $byCustomer,
'form' => $form->createView(), '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 TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
public const MIN_MINUTES_PER_ENTRY = 15; public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h 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 ADD_TAGS_MAX_ENTRIES = 10000;
public const MAX_TAG_PER_ENTRY = 3; public const MAX_TAG_PER_ENTRY = 3;

View File

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

View File

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

View File

@@ -47,7 +47,17 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
} }
if ($event->isIndexView() && $this->isGranted('delete', $timesheet)) { 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) { if ($activity->getId() === null) {
return; 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()])]); $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']); $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()])); $event->addDelete($this->path('admin_activity_delete', ['id' => $activity->getId()]));
} }
} }

View File

@@ -30,7 +30,7 @@ class CustomerSubscriber extends AbstractActionsSubscriber
return; 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()])]); $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)) { 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')) { if ($this->isGranted('view_reporting') && $this->isGranted('budget_project')) {

View File

@@ -30,7 +30,7 @@ class ProjectSubscriber extends AbstractActionsSubscriber
return; 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()])]); $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()])]); $event->addAction('copy', ['url' => $this->path('admin_project_duplicate', ['id' => $project->getId()])]);
} }
if ($event->isIndexView() && $this->isGranted('delete', $project)) { if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {
$event->addAction('trash', ['url' => $this->path('admin_project_delete', ['id' => $project->getId()]), 'class' => 'modal-ajax-form text-red']); $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)) { 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, 'wrapText' => false,
], ],
'exported' => [], 'exported' => [],
'billable' => [],
'tags' => [], 'tags' => [],
'hourlyRate' => [], 'hourlyRate' => [],
'fixedRate' => [], 'fixedRate' => [],
@@ -122,7 +123,16 @@ abstract class AbstractSpreadsheetRenderer
return; 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); $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
} }
@@ -134,7 +144,15 @@ abstract class AbstractSpreadsheetRenderer
return; 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); $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
} }
@@ -146,7 +164,16 @@ abstract class AbstractSpreadsheetRenderer
return; 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); $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'])) { if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) { $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)); $sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,22 +11,13 @@ namespace App\Model;
class TimesheetCountedStatistic class TimesheetCountedStatistic
{ {
/** private $recordAmount = 0;
* @var int private $recordDuration = 0;
*/ private $recordRate = 0.0;
protected $recordAmount = 0; private $recordInternalRate = 0.0;
/** private $recordAmountBillable = 0;
* @var int private $recordDurationBillable = 0;
*/ private $recordRateBillable = 0.0;
protected $recordDuration = 0;
/**
* @var float
*/
protected $recordRate = 0.0;
/**
* @var float
*/
protected $recordInternalRate = 0.0;
/** /**
* Returns the total amount of included timesheet records. * Returns the total amount of included timesheet records.
@@ -111,4 +102,34 @@ class TimesheetCountedStatistic
return $this; 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. * 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\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\ProjectRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Timesheet\DateTimeFactory; use App\Timesheet\DateTimeFactory;
use DateTime;
use DateTimeZone;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Exception;
final class ProjectViewService final class ProjectStatisticService
{ {
private $repository; private $repository;
private $timesheetRepository; private $timesheetRepository;
@@ -28,28 +34,58 @@ final class ProjectViewService
} }
/** /**
* @param ProjectViewQuery $query * @param ProjectInactiveQuery $query
* @return ProjectViewModel[] * @return Project[]
* @throws Exception
*/ */
public function getProjectView(ProjectViewQuery $query): array public function findInactiveProjects(ProjectInactiveQuery $query): array
{ {
$factory = new DateTimeFactory($query->getToday()->getTimezone());
$user = $query->getUser(); $user = $query->getUser();
$today = clone $query->getToday(); $lastChange = clone $query->getLastChange();
$now = new DateTime('now', $lastChange->getTimezone());
$begin = $factory->getStartOfWeek($today); $qb2 = $this->repository->createQueryBuilder('t1');
$end = $factory->getEndOfWeek($today); $qb2
$startMonth = (clone $begin)->modify('first day of this month'); ->select('1')
$endMonth = (clone $begin)->modify('last day of this month'); ->from(Timesheet::class, 't')
->andWhere('p = t.project')
->andWhere($qb2->expr()->gte('t.begin', ':begin'))
;
$qb = $this->repository->createQueryBuilder('p'); $qb = $this->repository->createQueryBuilder('p');
$qb $qb
->select('p AS project') ->select('p, c')
->addSelect('SUM(t.duration) AS totalDuration') ->leftJoin('p.customer', 'c')
->addSelect('SUM(t.rate) AS totalRate') ->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('p.customer', 'c')
->leftJoin(Timesheet::class, 't', 'WITH', 'p.id = t.project')
->andWhere($qb->expr()->eq('p.visible', true)) ->andWhere($qb->expr()->eq('p.visible', true))
->andWhere($qb->expr()->eq('c.visible', true)) ->andWhere($qb->expr()->eq('c.visible', true))
->andWhere( ->andWhere(
@@ -59,7 +95,6 @@ final class ProjectViewService
) )
) )
->addGroupBy('p') ->addGroupBy('p')
->addGroupBy('t.project')
->setParameter('project_end', $today, Types::DATETIME_MUTABLE) ->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
; ;
@@ -69,7 +104,10 @@ final class ProjectViewService
} }
if (!$query->isIncludeNoWork()) { 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()) { if (!$query->isIncludeNoBudget()) {
@@ -83,23 +121,59 @@ final class ProjectViewService
$this->repository->addPermissionCriteria($qb, $user); $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 = []; $projectViews = [];
foreach ($result as $res) { foreach ($projects as $project) {
$entity = new ProjectViewModel($res['project']); $projectViews[$project->getId()] = new ProjectViewModel($project);
$entity->setDurationTotal($res['totalDuration'] ?? 0);
$entity->setRateTotal($res['totalRate'] ?? 0.00);
$projectViews[$entity->getProject()->getId()] = $entity;
} }
$projectIds = array_keys($projectViews); $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 // values for today
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = $this->timesheetRepository->createQueryBuilder('t');
$qb $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($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) = :starting_date') ->andWhere('DATE(t.begin) = :starting_date')
->groupBy('t.project') ->groupBy('t.project')
@@ -109,13 +183,13 @@ final class ProjectViewService
$result = $qb->getQuery()->getScalarResult(); $result = $qb->getQuery()->getScalarResult();
foreach ($result as $row) { foreach ($result as $row) {
$projectViews[$row['id']]->setDurationDay($row['duration']); $projectViews[$row['id']]->setDurationDay($row['duration'] ?? 0);
} }
// values for the current week // values for the current week
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = $this->timesheetRepository->createQueryBuilder('t');
$qb $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($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date') ->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date')
->groupBy('t.project') ->groupBy('t.project')
@@ -132,7 +206,7 @@ final class ProjectViewService
// values for the current month // values for the current month
$qb = $this->timesheetRepository->createQueryBuilder('t'); $qb = $this->timesheetRepository->createQueryBuilder('t');
$qb $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($qb->expr()->in('t.project', ':project'))
->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month') ->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month')
->groupBy('t.project') ->groupBy('t.project')
@@ -146,10 +220,10 @@ final class ProjectViewService
$projectViews[$row['id']]->setDurationMonth($row['duration']); $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 = $this->timesheetRepository->createQueryBuilder('t');
$qb $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($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported') ->andWhere('t.exported = :exported')
->groupBy('t.project') ->groupBy('t.project')
@@ -163,10 +237,10 @@ final class ProjectViewService
$projectViews[$row['id']]->setNotExportedRate($row['rate']); $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 = $this->timesheetRepository->createQueryBuilder('t');
$qb $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($qb->expr()->in('t.project', ':project'))
->andWhere('t.exported = :exported') ->andWhere('t.exported = :exported')
->andWhere('t.billable = :billable') ->andWhere('t.billable = :billable')
@@ -182,6 +256,23 @@ final class ProjectViewService
$projectViews[$row['id']]->setNotBilledRate($row['rate']); $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); return array_values($projectViews);
} }
} }

View File

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

View File

@@ -20,6 +20,7 @@ use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface; use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery; use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery; use App\Repository\Query\ActivityQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
@@ -97,9 +98,9 @@ class ActivityRepository extends EntityRepository
$qb $qb
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount') ->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration') ->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('SUM(t.rate) as rate') ->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('SUM(t.internalRate) as internal_rate') ->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->where('t.activity = :activity') ->where('t.activity = :activity')
->setParameter('activity', $activity) ->setParameter('activity', $activity)
; ;
@@ -115,6 +116,26 @@ class ActivityRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']); $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; return $stats;
} }

View File

@@ -22,6 +22,7 @@ use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface; use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery; use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery; use App\Repository\Query\CustomerQuery;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
@@ -78,22 +79,16 @@ class CustomerRepository extends EntityRepository
return $this->count([]); return $this->count([]);
} }
/** public function getCustomerStatistics(Customer $customer): CustomerStatistic
* Retrieves statistics for one customer.
*
* @param Customer $customer
* @return CustomerStatistic
*/
public function getCustomerStatistics(Customer $customer)
{ {
$qb = $this->getEntityManager()->createQueryBuilder(); $qb = $this->getEntityManager()->createQueryBuilder();
$qb $qb
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id') ->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
->addSelect('COUNT(t.id) as amount') ->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration') ->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('SUM(t.rate) as rate') ->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('SUM(t.internalRate) as internal_rate') ->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('p.customer = :customer') ->andWhere('p.customer = :customer')
->setParameter('customer', $customer) ->setParameter('customer', $customer)
; ;
@@ -109,6 +104,27 @@ class CustomerRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']); $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 = $this->getEntityManager()->createQueryBuilder();
$qb $qb
->select('COUNT(a.id) as amount') ->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\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery; use App\Repository\Query\ProjectQuery;
use DateTime; use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\ORMException; use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
@@ -84,18 +85,14 @@ class ProjectRepository extends EntityRepository
$qb $qb
->from(Timesheet::class, 't') ->from(Timesheet::class, 't')
->addSelect('COUNT(t.id) as amount') ->addSelect('COUNT(t.id) as amount')
->addSelect('SUM(t.duration) as duration') ->addSelect('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('SUM(t.rate) as rate') ->addSelect('COALESCE(SUM(t.rate), 0) as rate')
->addSelect('SUM(t.internalRate) as internal_rate') ->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
->andWhere('t.project = :project') ->andWhere('t.project = :project')
->setParameter('project', $project) ->setParameter('project', $project)
; ;
if (null !== $begin) { // to calculate a budget at a certain point in time
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $begin);
}
if (null !== $end) { if (null !== $end) {
$qb->andWhere($qb->expr()->lte('t.end', ':end')) $qb->andWhere($qb->expr()->lte('t.end', ':end'))
->setParameter('end', $end); ->setParameter('end', $end);
@@ -112,6 +109,32 @@ class ProjectRepository extends EntityRepository
$stats->setRecordInternalRate($timesheetResult['internal_rate']); $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 = $this->getEntityManager()->createQueryBuilder();
$qb $qb
->from(Activity::class, 'a') ->from(Activity::class, 'a')

View File

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

View File

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

View File

@@ -73,4 +73,26 @@ final class ThemeExtension implements RuntimeExtensionInterface
return $event->getTranslations(); 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 [ return [
new TwigFunction('trigger', [ThemeExtension::class, 'trigger'], ['needs_environment' => true]), new TwigFunction('trigger', [ThemeExtension::class, 'trigger'], ['needs_environment' => true]),
new TwigFunction('actions', [ThemeExtension::class, 'actions']), new TwigFunction('actions', [ThemeExtension::class, 'actions']),
new TwigFunction('progressbar_color', [ThemeExtension::class, 'getProgressbarClass']),
new TwigFunction('javascript_translations', [ThemeExtension::class, 'getJavascriptTranslations']), new TwigFunction('javascript_translations', [ThemeExtension::class, 'getJavascriptTranslations']),
new TwigFunction('active_timesheets', [TimesheetExtension::class, 'activeEntries']), new TwigFunction('active_timesheets', [TimesheetExtension::class, 'activeEntries']),
new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']), new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']),

View File

@@ -4,10 +4,10 @@
{{ widgets.page_actions(event.actions) }} {{ widgets.page_actions(event.actions) }}
{% endmacro %} {% endmacro %}
{% macro activity(activity, view, options) %} {% macro activity(activity, view, isTable) %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'activity', view, {'activity': activity}) %} {% 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) }} {{ widgets.table_actions(event.actions) }}
{% else %} {% else %}
{{ widgets.page_actions(event.actions) }} {{ widgets.page_actions(event.actions) }}

View File

@@ -3,7 +3,7 @@
{% import "activity/actions.html.twig" as actions %} {% import "activity/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% 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 %} {% block main %}
{% set can_edit = is_granted('edit', activity) %} {% set can_edit = is_granted('edit', activity) %}
@@ -42,7 +42,7 @@
</tr> </tr>
{% endif %} {% endif %}
{% if not activity.global %} {% 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> <th>{{ 'label.customer'|trans }}</th>
<td> <td>
{{ widgets.label_customer(activity.project.customer) }} {{ widgets.label_customer(activity.project.customer) }}
@@ -54,7 +54,7 @@
{{ customerActions.customer(activity.project.customer, 'custom') }} {{ customerActions.customer(activity.project.customer, 'custom') }}
</td> </td>
</tr> </tr>
<tr class="{{ widgets.class_project_row(activity.project, now) }}"> <tr {{ widgets.project_row_attr(activity.project, now) }}>
<th>{{ 'label.project'|trans }}</th> <th>{{ 'label.project'|trans }}</th>
<td> <td>
{{ widgets.label_project(activity.project) }} {{ widgets.label_project(activity.project) }}
@@ -78,7 +78,11 @@
{% endembed %} {% endembed %}
{% if stats is not null %} {% 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 %} {% endif %}
{% if can_edit %} {% if can_edit %}
@@ -102,7 +106,7 @@
{{ parent() }} {{ parent() }}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('kimai.initialized', function() { 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> </script>
{% endblock %} {% 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 %} {% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.activityUpdate') }} {{ widgets.nothing_found('kimai.activityUpdate') }}
{% else %} {% 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 %} {% for entry in entries %}
{% set class = widgets.class_activity_row(entry, now) %} <tr {{ widgets.activity_row_attr(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 }}">
<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, 'name') }}">{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{# only none-global activities have a project and customer assigned #} {# only none-global activities have a project and customer assigned #}

View File

@@ -2,7 +2,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "customer/actions.html.twig" as actions %} {% 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 page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
{% block main %} {% block main %}
@@ -121,7 +121,7 @@
{{ render(controller('App\\Controller\\CustomerController::projectsAction', {'customer': customer.id, 'page': 1})) }} {{ render(controller('App\\Controller\\CustomerController::projectsAction', {'customer': customer.id, 'page': 1})) }}
{% if stats is not null %} {% 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 %} {% endif %}
{% if can_edit %} {% if can_edit %}
@@ -150,7 +150,7 @@
{{ parent() }} {{ parent() }}
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('kimai.initialized', function() { 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> </script>
{% endblock %} {% 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 %} {% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'menu.admin_project'|trans }}{% endblock %} {% block box_title %}{{ 'menu.admin_project'|trans }}{% endblock %}
{% block box_attributes %} {% 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 %} {% endblock %}
{% block box_tools %} {% block box_tools %}
{% if is_granted('view_reporting') and is_granted('budget_project') %} {% if is_granted('view_reporting') and is_granted('budget_project') %}
@@ -35,7 +35,7 @@
</thead> </thead>
<tbody> <tbody>
{% for project in projects %} {% 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>{{ widgets.label_project(project) }}</td>
<td class="hidden-xs">{{ project.comment|comment1line(not app.user.smallLayout) }}</td> <td class="hidden-xs">{{ project.comment|comment1line(not app.user.smallLayout) }}</td>
<td class="w-min text-center"> <td class="w-min text-center">
@@ -47,7 +47,7 @@
</td> </td>
<td class="w-min text-center">{{ widgets.label_visible(project.visible) }}</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="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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -48,16 +48,10 @@
{% if entries.count == 0 %} {% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.customerUpdate kimai.customerTeamUpdate') }} {{ widgets.nothing_found('kimai.customerUpdate kimai.customerTeamUpdate') }}
{% else %} {% 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 %} {% for entry in entries %}
{% set class = widgets.class_customer_row(entry, now) %} <tr {{ widgets.customer_row_attr(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 }}">
<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, '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, 'comment') }}">{{ entry.comment|comment1line }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</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}, 'description': {'class': 'hidden-xs hidden-sm hidden-md timesheet-description', 'orderBy': false},
'unit_price': {'class': 'hidden hidden-xs text-nowrap', 'orderBy': false}, 'unit_price': {'class': 'hidden hidden-xs text-nowrap', 'orderBy': false},
'duration': {'class': '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}, 'total_rate': {'class': 'text-nowrap', 'orderBy': false},
'exported': {'class': 'alwaysVisible', 'orderBy': false}, 'exported': {'class': 'alwaysVisible', 'orderBy': false},
} %} } %}
@@ -32,6 +32,7 @@
{% block main %} {% block main %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/search.html.twig" as search %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %} {% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'export.filter'|trans }}{% endblock %} {% block box_title %}{{ 'export.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %} {% block box_before %}{{ form_start(form) }}{% endblock %}
@@ -46,11 +47,28 @@
{% if form.users is defined %} {% if form.users is defined %}
{{ form_row(form.users) }} {{ form_row(form.users) }}
{% endif %} {% endif %}
{{ form_row(form.billable) }}
{{ form_row(form.exported) }} {{ form_row(form.exported) }}
{{ form_row(form.state) }} {{ form_row(form.state) }}
{{ form_row(form.markAsExported) }} {{ form_row(form.markAsExported) }}
{% endblock %} {% endblock %}
{% block box_footer%} {% block box_footer%}
{{ search.searchButton(form) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %}
{% if preview_show %}
{% 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 = {} %} {% set buttons = {} %}
{% for button in renderer %} {% for button in renderer %}
{% set title = button.title %} {% set title = button.title %}
@@ -82,37 +100,62 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{{ form_widget(form.preview) }}
{% endblock %} {% endblock %}
{% block box_after %}{{ form_end(form) }}{% 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 %} {% endembed %}
{% if preview_show %} {% set itemsAmount = entries|length %}
{% if entries is empty %} {% if preview_limit %}
{{ widgets.nothing_found() }} {% set entries = entries|slice(0, preview_limit) %}
{% else %} {% endif %}
{{ tables.datatable_header(tableName, columns, query, {}) }} {{ tables.datatable_header(tableName, columns, query, {}) }}
{% set totalAmount = {} %}
{% set totalInternalAmount = {} %}
{% set totalDuration = 0 %}
{% for entry in entries %} {% for entry in entries %}
{% set currency = entry.project.customer.currency %} {% 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 %} {% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %} {% set rate = entry.fixedRate %}
{% else %} {% else %}
{% set rate = entry.hourlyRate %} {% set rate = entry.hourlyRate %}
{% endif %} {% 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> <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, 'date') }}">
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td> {{ 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') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }} {{ widgets.label_project(entry.project) }}
<br> <br>
@@ -130,7 +173,7 @@
{{ rate|money(currency) }} {{ rate|money(currency) }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
{{ duration }} {{ entry.duration|duration(decimal) }}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}">
{{ entry.internalRate|money(currency) }} {{ entry.internalRate|money(currency) }}
@@ -161,30 +204,11 @@
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
<tr> {% if preview_limit and itemsAmount > preview_limit %}
<th class="{{ tables.data_table_column_class(tableName, columns, 'date') }}"></th> <tr class="warning">
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th> <td colspan="10">&raquo; {{ 'preview.skipped_rows'|trans({'%rows%': (itemsAmount - preview_limit)}) }}</td>
<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> </tr>
{% endif %}
{{ tables.data_table_footer(entries) }} {{ tables.data_table_footer(entries) }}
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -201,7 +225,7 @@
var message = '{{ 'export.clear_all'|trans }}'; var message = '{{ 'export.clear_all'|trans }}';
var hasActive = true; var hasActive = true;
if (!$('#export-toggle-button').hasClass('export-off')) { if (!jQuery('#export-toggle-button').hasClass('export-off')) {
message = '{{ 'export.mark_all'|trans }}'; message = '{{ 'export.mark_all'|trans }}';
hasActive = false; hasActive = false;
} }
@@ -210,8 +234,8 @@
if (!value) { if (!value) {
return; return;
} }
var btn = $(button); var btn = jQuery(button);
var exportButtons = $('.exportBtn'); var exportButtons = jQuery('.exportBtn');
// disabling does not yet work... // disabling does not yet work...
if (exportButtons.length > 0) { if (exportButtons.length > 0) {
btn.addClass('disabled'); btn.addClass('disabled');
@@ -219,8 +243,8 @@
// ... as the clicks are asynchronous and the each comes back too early ... // ... as the clicks are asynchronous and the each comes back too early ...
exportButtons.each(function () { exportButtons.each(function () {
if (hasActive === $(this).hasClass('active')) { if (hasActive === jQuery(this).hasClass('active')) {
$(this).click(); jQuery(this).click();
} }
}); });
@@ -254,11 +278,11 @@
} else { } else {
button.button('clean'); 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() { button.closest('tr').hide('ease', function() {
$(this).remove(); jQuery(this).remove();
if($(this).closest('table').find('tbody tr:visible').length === 0) { if(jQuery(this).closest('table').find('tbody tr:visible').length === 0) {
$('#export-buttons button').prop('disabled', true); jQuery('#export-buttons button').prop('disabled', true);
} }
}); });
} }
@@ -286,24 +310,24 @@
} }
document.addEventListener('kimai.initialized', function() { document.addEventListener('kimai.initialized', function() {
$('body').on('click', '.exportBtn', function() { jQuery('body').on('click', '.exportBtn', function() {
var button = $(this); var button = jQuery(this);
var id = button.attr('data-timesheet'); var id = button.attr('data-timesheet');
updateTimesheetExportState(button, id, !button.hasClass('active')); updateTimesheetExportState(button, id, !button.hasClass('active'));
}); });
$('#export-toggle-button').on('click', function () { jQuery('#export-toggle-button').on('click', function () {
confirmToggleState(this); confirmToggleState(this);
}); });
$('body').on('click', '#export-buttons .startExportBtn', function() { jQuery('body').on('click', '#export-buttons .startExportBtn', function() {
$('#renderer').val($(this).attr('data-type')); jQuery('#renderer').val(jQuery(this).attr('data-type'));
var $form = $("#export-form"); var $form = jQuery("#export-form");
var prevAction = $form.attr('action'); var prevAction = $form.attr('action');
var prevMethod = $form.attr('method'); var prevMethod = $form.attr('method');
$form.attr('target', '_blank').attr('method', 'POST').attr('action', '{{ path('export_data') }}'); $form.attr('target', '_blank').attr('method', 'POST').attr('action', '{{ path('export_data') }}');
$form.submit(); $form.submit();
$('#renderer').val(''); jQuery('#renderer').val('');
$form.removeAttr('target').attr('action', prevAction).attr('method', prevMethod); $form.removeAttr('target').attr('action', prevAction).attr('method', prevMethod);
}); });
}); });

View File

@@ -85,7 +85,7 @@
{% endblock %} {% endblock %}
{% block box_body_class %}no-padding{% endblock %} {% block box_body_class %}no-padding{% endblock %}
{% block box_footer %} {% 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 }} {{ 'action.save_all'|trans }}
</a> </a>
{% endblock %} {% endblock %}
@@ -109,8 +109,8 @@
</td> </td>
<td class="w-min text-center"> <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('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('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})}}, 'primary') }} {{ 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>
<td class="w-min text-center hidden-xs"> <td class="w-min text-center hidden-xs">
{{ model.calculator.timeWorked|duration(isDecimal) }} {{ 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 %} {% set percentReached = 0 %}
{% if max > 0 %} {% if max > 0 %}
{% set percentReached = (current / (max / 100)) %} {% set percentReached = (current / (max / 100)) %}
{% endif %} {% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %} {% 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 %} {% if width > 100 %}
{% set width = 100 %} {% set width = 100 %}
{% endif %} {% endif %}
@@ -35,36 +24,25 @@
</span> </span>
</div> </div>
<div class="progress"> <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>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro progressbar_small(max, current, leftValue, leftFormatted) %} {% macro progressbar_small(max, current, leftValue, leftFormatted, reverseColors) %}
{% set percentReached = 0 %} {% set percentReached = 0 %}
{% if max > 0 %} {% if max > 0 %}
{% set percentReached = (current / (max / 100)) %} {% set percentReached = (current / (max / 100)) %}
{% endif %} {% endif %}
{% set class = "progress-bar-info" %}
{% set width = percentReached|number_format(1, '.', '') %} {% 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 %} {% if width > 100 %}
{% set width = 100 %} {% set width = 100 %}
{% endif %} {% endif %}
<div class="progress-group"> <div class="progress-group">
<div class="progress progress-sm"> <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> </div>
{% if leftValue is not null and leftValue > 0 %} {% if leftValue is not null and leftValue > 0 %}
<small>{{ 'stats.percentUsedLeft'|trans({'%percent%': percentReached|number_format(0), '%left%': leftFormatted}) }}</small> <small>{{ 'stats.percentUsedLeft'|trans({'%percent%': percentReached|number_format(0), '%left%': leftFormatted}) }}</small>

View File

@@ -504,17 +504,105 @@
{% endif %} {% endif %}
{% endmacro %} {% 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) %} {% macro class_customer_row(customer, now) %}
{%- if not customer.visible %}warning{% endif -%} {%- if not customer.visible %}warning{% endif -%}
{% endmacro %} {% 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) %} {% macro class_project_row(project, now) %}
{%- if not project.visible or (project.end is not null and project.end < now) %}warning{% endif -%} {%- if not project.visible or (project.end is not null and project.end < now) %}warning{% endif -%}
{% endmacro %} {% 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) %} {% macro class_activity_row(activity, now) %}
{%- if not activity.visible %}warning{% endif -%} {%- if not activity.visible %}warning{% endif -%}
{% endmacro %} {% 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) }} {{ widgets.page_actions(event.actions) }}
{% endmacro %} {% endmacro %}
{% macro project(project, view) %} {% macro project(project, view, isTable) %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% set event = actions(app.user, 'project', view, {'project': project}) %} {% 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) }} {{ widgets.table_actions(event.actions) }}
{% else %} {% else %}
{{ widgets.page_actions(event.actions) }} {{ widgets.page_actions(event.actions) }}

View File

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

View File

@@ -3,7 +3,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% block box_title %}{{ 'menu.admin_activity'|trans }}{% endblock %} {% block box_title %}{{ 'menu.admin_activity'|trans }}{% endblock %}
{% block box_attributes %} {% 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 %} {% endblock %}
{% block box_tools %} {% block box_tools %}
{% if project.visible and project.customer.visible and is_granted('create_activity') %} {% if project.visible and project.customer.visible and is_granted('create_activity') %}
@@ -31,12 +31,12 @@
</thead> </thead>
<tbody> <tbody>
{% for activity in activities %} {% 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>{{ widgets.label_activity(activity) }}</td>
<td class="hidden-xs">{{ activity.comment|comment1line(not app.user.smallLayout) }}</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.label_visible(activity.visible) }}</td>
<td class="w-min text-center">{{ widgets.badge_team_access(activity.teams) }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </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 %} {% if entries.count == 0 %}
{{ widgets.nothing_found('kimai.projectUpdate kimai.projectTeamUpdate') }} {{ widgets.nothing_found('kimai.projectUpdate kimai.projectTeamUpdate') }}
{% else %} {% 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 %} {% for entry in entries %}
{% set class = widgets.class_project_row(entry, now) %} <tr {{ widgets.project_row_attr(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 }}">
<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, '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, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</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' %} {% extends 'reporting/layout.html.twig' %}
{% import "macros/datatables.html.twig" as tables %} {% 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 = { {% set columns = {
'name': {'class': 'alwaysVisible'}, '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}, '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}, '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}, '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}, 'comment': {'class': 'hidden-md hidden-sm hidden-xs hidden', 'title': 'label.comment'|trans},
'actions': {'class': 'actions alwaysVisible'}, 'actions': {'class': 'actions alwaysVisible'},
} %} } %}
{% set tableName = 'project_view_reporting' %} {% set tableName = tableName|default('project_view_reporting') %}
{% block main_before %} {% block main_before %}
{{ tables.data_table_column_modal(tableName, columns) }} {{ tables.data_table_column_modal(tableName, columns) }}
@@ -32,7 +33,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %} {% import "macros/datatables.html.twig" as tables %}
{% import "project/actions.html.twig" as projectActions %} {% 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 %} {% block box_before %}
{{ form_start(form, {'attr': {'class': 'form-inline form-reporting', 'id': 'project-view-form'}}) }} {{ form_start(form, {'attr': {'class': 'form-inline form-reporting', 'id': 'project-view-form'}}) }}
{% endblock %} {% endblock %}
@@ -40,7 +41,7 @@
{{ form_end(form) }} {{ form_end(form) }}
{% endblock %} {% endblock %}
{% block box_tools %} {% 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 %} {% endblock %}
{% block box_title %} {% block box_title %}
{{ form_widget(form) }} {{ form_widget(form) }}
@@ -53,26 +54,33 @@
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %} {% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
<tr class="summary"> <tr class="summary">
<td colspan="11">{{ widgets.label_customer(mapping.customer) }}</td> <td colspan="{{ columns|length }}">{{ widgets.label_customer(mapping.customer) }}</td>
</tr> </tr>
{% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %} {% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %}
{% set project = entry.project %} {% set project = entry.project %}
{% set currency = project.customer.currency %} {% set currency = project.customer.currency %}
{% if is_granted('budget', project) %} {% 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 %}> <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, '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, '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, '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, '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, 'durationTotal') }}">{{ entry.durationTotal|duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
{% if project.timeBudget > 0 %} {% if project.timeBudget > 0 %}
{{ progress.progressbar(project.timeBudget, entry.durationTotal|default(0), entry.durationTotal|duration ~ ' / ' ~ project.timeBudget|duration, '') }} {{ progress.progressbar(project.timeBudget, entry.billableDuration|default(0), entry.billableDuration|duration ~ ' / ' ~ project.timeBudget|duration, '') }}
{% endif %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
{% if project.budget > 0 %} {% if project.budget > 0 %}
{{ progress.progressbar(project.budget, entry.rateTotal|default(0), entry.rateTotal|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }} {{ progress.progressbar(project.budget, entry.billableRate|default(0), entry.billableRate|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }}
{% endif %} {% endif %}
</td> </td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}"> <td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}">

View File

@@ -88,6 +88,26 @@
</div> </div>
</div> </div>
{% endif %} {% 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) }} {{ form_widget(form) }}
{% endblock %} {% endblock %}
{% block form_after %} {% block form_after %}

View File

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

View File

@@ -42,7 +42,7 @@
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }} {{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}
{% for entry in entries %} {% 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, '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, 'alias') }}">{{ widgets.username(entry) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>

View File

@@ -53,8 +53,20 @@
tooltips: { tooltips: {
callbacks: { callbacks: {
label: function(tooltipItem, data) { 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')) -%} {%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
@@ -95,7 +107,8 @@
], ],
realData: [ realData: [
{% for month in yearStat.months %} {% for month in yearStat.months %}
'{{ month.totalDuration|duration }}' {% set realDayData = {duration: month.totalDuration|duration, billable: month.billableDuration|duration} %}
{{ realDayData|json_encode|raw }}
{% if not loop.last %},{% endif %} {% if not loop.last %},{% endif %}
{% endfor %} {% endfor %}
], ],

View File

@@ -55,7 +55,7 @@
{%- for entry in day.details -%} {%- for entry in day.details -%}
{% set loopId = (entry.project.id ~ '_' ~ entry.activity.id) %} {% set loopId = (entry.project.id ~ '_' ~ entry.activity.id) %}
{%- if loopId == activityId -%} {%- 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 -%} {%- endif -%}
{%- endfor -%} {%- endfor -%}
{%- if realDayData is not null -%} {%- if realDayData is not null -%}
@@ -117,7 +117,7 @@
footer: function(tooltipItems, data) { footer: function(tooltipItems, data) {
var tooltipItem = tooltipItems[0]; var tooltipItem = tooltipItems[0];
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index]; 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) { beforeFooter: function(tooltipItems, data) {
return ' '; return ' ';

View File

@@ -35,10 +35,10 @@
<td style="width:50%"> <td style="width:50%">
{% if project.timeBudget is not empty and project.timeBudget > 0 %} {% if project.timeBudget is not empty and project.timeBudget > 0 %}
{% set budgetLeft = project.timeBudget - stats.recordDuration %} {% 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 %} {% elseif project.budget is not empty and project.budget > 0 %}
{% set budgetLeft = project.budget - stats.recordRate %} {% 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 %} {% endif %}
</td> </td>
</tr> </tr>

View File

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

View File

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

View File

@@ -199,12 +199,12 @@ abstract class ControllerBaseTest extends WebTestCase
/** /**
* @param HttpKernelBrowser $client * @param HttpKernelBrowser $client
* @param string $id * @param string $class
* @param int $count * @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()); self::assertEquals($count, $node->count());
} }

View File

@@ -114,6 +114,8 @@ class CustomerControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#project_list_box'); $node = $client->getCrawler()->filter('div.box#project_list_box');
self::assertEquals(1, $node->count()); 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'); $node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box'); $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); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/?preview='); $this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client); $this->assertHasNoEntriesWithFilter($client);
@@ -83,13 +83,13 @@ class ExportControllerTest extends ControllerBaseTest
$this->importFixture($fixture); $this->importFixture($fixture);
$em->flush(); $em->flush();
$this->request($client, '/export/?preview='); $this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed // make sure all existing records are displayed
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
// +1 row for summary // +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 23); $this->assertDataTableRowCount($client, 'datatable_export', 22);
// assert export type buttons are available // assert export type buttons are available
$expected = [ $expected = [
@@ -113,7 +113,6 @@ class ExportControllerTest extends ControllerBaseTest
public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam() public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $this->getEntityManager();
$begin = new \DateTime('first day of this month'); $begin = new \DateTime('first day of this month');
$user = $this->getUserByRole(User::ROLE_USER); $user = $this->getUserByRole(User::ROLE_USER);
@@ -127,10 +126,10 @@ class ExportControllerTest extends ControllerBaseTest
; ;
$this->importFixture($fixture); $this->importFixture($fixture);
$this->request($client, '/export/?preview='); $this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed // make sure existing records are not displayed
$this->assertHasNoEntriesWithFilter($client); $this->assertHasNoEntriesWithFilter($client);
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD); $teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
@@ -143,13 +142,13 @@ class ExportControllerTest extends ControllerBaseTest
; ;
$this->importFixture($fixture); $this->importFixture($fixture);
$this->request($client, '/export/?preview='); $this->request($client, '/export/?performSearch=performSearch');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed // make sure all existing records are displayed
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
// +1 row for summary // +1 row for summary
$this->assertDataTableRowCount($client, 'datatable_export', 3); $this->assertDataTableRowCount($client, 'datatable_export', 2);
// assert export type buttons are available // assert export type buttons are available
$expected = [ $expected = [

View File

@@ -148,6 +148,8 @@ class ProjectControllerTest extends ControllerBaseTest
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#activity_list_box'); $node = $client->getCrawler()->filter('div.box#activity_list_box');
self::assertEquals(1, $node->count()); 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'); $node = $client->getCrawler()->filter('div.box#budget_box');
self::assertEquals(1, $node->count()); self::assertEquals(1, $node->count());
$node = $client->getCrawler()->filter('div.box#team_listing_box'); $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 class ProjectViewControllerTest extends ControllerBaseTest
{ {
public function testProjectViewIsSecure() public function testReportIsSecure()
{ {
$this->assertUrlIsSecured('/reporting/project_view'); $this->assertUrlIsSecured('/reporting/project_view');
} }
public function testProjectViewReport() public function testReport()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -39,7 +39,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
$projects->setCustomers($customers); $projects->setCustomers($customers);
$projects->setAmount(2); $projects->setAmount(2);
$projects->setIsVisible(true); $projects->setIsVisible(true);
$projects = $this->importFixture($projects); $this->importFixture($projects);
$activities = new ActivityFixtures(); $activities = new ActivityFixtures();
$activities->setAmount(5); $activities->setAmount(5);
@@ -53,7 +53,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
$this->importFixture($timesheets); $this->importFixture($timesheets);
$this->assertAccessIsGranted($client, '/reporting/project_view'); $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')]"); $rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
self::assertGreaterThan(0, $rows->count()); self::assertGreaterThan(0, $rows->count());
} }

View File

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

View File

@@ -91,25 +91,26 @@ class CsvRendererTest extends AbstractRendererTest
10 => 'activity description', 10 => 'activity description',
11 => '', 11 => '',
12 => '', 12 => '',
13 => 'foo,bar', 13 => '',
14 => '', 14 => 'foo,bar',
15 => ' EUR 84.00 ', 15 => '',
16 => 'meta-bar', 16 => ' EUR 84.00 ',
17 => 'meta-bar2', 17 => 'meta-bar',
18 => 'customer-bar', 18 => 'meta-bar2',
19 => '', 19 => 'customer-bar',
20 => 'project-foo2', 20 => '',
21 => 'activity-bar', 21 => 'project-foo2',
22 => 'timesheet', 22 => 'activity-bar',
23 => 'work', 23 => 'timesheet',
24 => 'A-0123456789', 24 => 'work',
25 => 'DE-9876543210', 25 => 'A-0123456789',
26 => 'ORDER-123', 26 => 'DE-9876543210',
27 => 'ORDER-123',
]; ];
self::assertEquals(7, \count($all)); self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0])); 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', 10 => 'activity description',
11 => '', 11 => '',
12 => '', 12 => '',
13 => 'foo,bar', 13 => '',
14 => '', 14 => 'foo,bar',
15 => ' EUR 84.00 ', 15 => '',
16 => 'meta-bar', 16 => ' EUR 84.00 ',
17 => 'meta-bar2', 17 => 'meta-bar',
18 => 'customer-bar', 18 => 'meta-bar2',
19 => '', 19 => 'customer-bar',
20 => 'project-foo2', 20 => '',
21 => 'activity-bar', 21 => 'project-foo2',
22 => 'timesheet', 22 => 'activity-bar',
23 => 'work', 23 => 'timesheet',
24 => 'A-0123456789', 24 => 'work',
25 => 'DE-9876543210', 25 => 'A-0123456789',
26 => 'ORDER-123', 26 => 'DE-9876543210',
27 => 'ORDER-123',
]; ];
self::assertEquals(7, \count($all)); self::assertEquals(7, \count($all));
self::assertEquals($expected, $all[5]); self::assertEquals($expected, $all[5]);
self::assertEquals(\count($expected), \count($all[0])); 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'); $date = new DateTime('-8 hours');
$sut = new Day($date, 12340, 197.25956); $sut = new Day($date, 12340, 197.25956);
$this->assertSame($date, $sut->getDay()); self::assertSame($date, $sut->getDay());
$this->assertEquals([], $sut->getDetails()); self::assertEquals([], $sut->getDetails());
$this->assertEquals(12340, $sut->getTotalDuration()); self::assertSame(12340, $sut->getTotalDuration());
$this->assertEquals(197.25956, $sut->getTotalRate()); self::assertSame(197.25956, $sut->getTotalRate());
self::assertSame(0, $sut->getTotalDurationBillable());
} }
public function testAllowedMonths() public function testAllowedMonths()
@@ -36,9 +37,11 @@ class DayTest extends TestCase
$sut->setTotalDuration(999); $sut->setTotalDuration(999);
$sut->setTotalRate(0.123456789); $sut->setTotalRate(0.123456789);
$sut->setTotalDurationBillable(12345);
$this->assertEquals(999, $sut->getTotalDuration()); self::assertSame(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate()); self::assertSame(0.123456789, $sut->getTotalRate());
self::assertSame(12345, $sut->getTotalDurationBillable());
} }
public function testSetDetails() public function testSetDetails()
@@ -47,6 +50,6 @@ class DayTest extends TestCase
$sut->setDetails(['foo' => ['bar' => '1212e'], 'hello' => 'world']); $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() public function testDefaultValues()
{ {
$sut = new Month('01'); $sut = new Month('01');
$this->assertEquals('01', $sut->getMonth()); self::assertSame('01', $sut->getMonth());
$this->assertEquals(0, $sut->getTotalDuration()); self::assertSame(0, $sut->getTotalDuration());
$this->assertEquals(0, $sut->getTotalRate()); self::assertSame(0.0, $sut->getTotalRate());
self::assertSame(0, $sut->getBillableDuration());
self::assertSame(0.0, $sut->getBillableRate());
} }
public function testAllowedMonths() public function testAllowedMonths()
@@ -35,7 +37,7 @@ class MonthTest extends TestCase
for ($i = 10; $i < 13; $i++) { for ($i = 10; $i < 13; $i++) {
new Month((string) $i); new Month((string) $i);
} }
$this->assertTrue(true); self::assertTrue(true);
} }
public function testInvalidMonths() public function testInvalidMonths()
@@ -47,8 +49,8 @@ class MonthTest extends TestCase
} catch (Exception $e) { } catch (Exception $e) {
$ex = $e; $ex = $e;
} }
$this->assertInstanceOf(InvalidArgumentException::class, $ex); self::assertInstanceOf(InvalidArgumentException::class, $ex);
$this->assertEquals( self::assertEquals(
'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".', 'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".',
$ex->getMessage() $ex->getMessage()
); );
@@ -60,8 +62,12 @@ class MonthTest extends TestCase
$sut = new Month('01'); $sut = new Month('01');
$sut->setTotalDuration(999); $sut->setTotalDuration(999);
$sut->setTotalRate(0.123456789); $sut->setTotalRate(0.123456789);
$sut->setBillableDuration(123456);
$sut->setBillableRate(123.456789);
$this->assertEquals(999, $sut->getTotalDuration()); self::assertSame(999, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate()); 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() public function testDefaultValues()
{ {
$sut = new Year('1999'); $sut = new Year('1999');
$this->assertNull($sut->getMonth(1)); self::assertNull($sut->getMonth(1));
$this->assertEmpty($sut->getMonths()); self::assertEmpty($sut->getMonths());
$this->assertIsArray($sut->getMonths()); self::assertIsArray($sut->getMonths());
$this->assertEquals('1999', $sut->getYear()); self::assertEquals('1999', $sut->getYear());
} }
public function testSetter() public function testSetter()
@@ -34,15 +34,15 @@ class YearTest extends TestCase
$sut->setMonth(new Month('01')); $sut->setMonth(new Month('01'));
$sut->setMonth(new Month('02')); $sut->setMonth(new Month('02'));
$sut->setMonth(new Month('03')); $sut->setMonth(new Month('03'));
$this->assertEquals(3, \count($sut->getMonths())); self::assertEquals(3, \count($sut->getMonths()));
$sut->setMonth(new Month('01')); $sut->setMonth(new Month('01'));
$this->assertEquals(3, \count($sut->getMonths())); self::assertEquals(3, \count($sut->getMonths()));
$this->assertInstanceOf(Month::class, $sut->getMonth(1)); self::assertInstanceOf(Month::class, $sut->getMonth(1));
$this->assertInstanceOf(Month::class, $sut->getMonth(2)); self::assertInstanceOf(Month::class, $sut->getMonth(2));
$this->assertInstanceOf(Month::class, $sut->getMonth(3)); self::assertInstanceOf(Month::class, $sut->getMonth(3));
$this->assertNull($sut->getMonth(4)); self::assertNull($sut->getMonth(4));
} }
} }

View File

@@ -20,10 +20,13 @@ class TimesheetCountedStatisticTest extends TestCase
public function testDefaultValues() public function testDefaultValues()
{ {
$sut = new TimesheetCountedStatistic(); $sut = new TimesheetCountedStatistic();
self::assertEquals(0.0, $sut->getRecordRate()); self::assertSame(0.0, $sut->getRecordRate());
self::assertEquals(0, $sut->getRecordDuration()); self::assertSame(0, $sut->getRecordDuration());
self::assertEquals(0, $sut->getRecordAmount()); self::assertSame(0, $sut->getRecordAmount());
self::assertEquals(0.0, $sut->getRecordInternalRate()); 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() public function testSetter()
@@ -34,9 +37,17 @@ class TimesheetCountedStatisticTest extends TestCase
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordAmount(5)); self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordAmount(5));
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09)); self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09));
self::assertEquals(23.97, $sut->getRecordRate()); self::assertSame(23.97, $sut->getRecordRate());
self::assertEquals(21, $sut->getRecordDuration()); self::assertSame(21, $sut->getRecordDuration());
self::assertEquals(5, $sut->getRecordAmount()); self::assertSame(5, $sut->getRecordAmount());
self::assertEquals(99.09, $sut->getRecordInternalRate()); 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); $sut = new ProjectViewModel($project);
self::assertSame($project, $sut->getProject()); self::assertSame($project, $sut->getProject());
self::assertEquals(0, $sut->getDurationDay()); self::assertSame(0, $sut->getDurationDay());
self::assertEquals(0, $sut->getDurationMonth()); self::assertSame(0, $sut->getDurationMonth());
self::assertEquals(0, $sut->getDurationTotal()); self::assertSame(0, $sut->getDurationTotal());
self::assertEquals(0, $sut->getDurationWeek()); self::assertSame(0, $sut->getDurationWeek());
self::assertEquals(0, $sut->getNotExportedDuration()); self::assertSame(0, $sut->getNotExportedDuration());
self::assertEquals(0, $sut->getNotExportedRate()); self::assertSame(0.0, $sut->getNotExportedRate());
self::assertEquals(0, $sut->getRateTotal()); 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() public function testSetterGetter()
{ {
$sut = new ProjectViewModel(new Project()); $sut = new ProjectViewModel(new Project());
$date = new \DateTime();
$sut->setDurationDay(123456789); $sut->setDurationDay(123456789);
$sut->setDurationMonth(23456789); $sut->setDurationMonth(23456789);
$sut->setDurationTotal(3456789); $sut->setDurationTotal(3456789);
@@ -44,13 +53,30 @@ class ProjectViewModelTest extends TestCase
$sut->setNotExportedDuration(56789); $sut->setNotExportedDuration(56789);
$sut->setNotExportedRate(6789); $sut->setNotExportedRate(6789);
$sut->setRateTotal(789); $sut->setRateTotal(789);
$sut->setLastRecord($date);
self::assertEquals(123456789, $sut->getDurationDay()); self::assertSame(123456789, $sut->getDurationDay());
self::assertEquals(23456789, $sut->getDurationMonth()); self::assertSame(23456789, $sut->getDurationMonth());
self::assertEquals(3456789, $sut->getDurationTotal()); self::assertSame(3456789, $sut->getDurationTotal());
self::assertEquals(456789, $sut->getDurationWeek()); self::assertSame(456789, $sut->getDurationWeek());
self::assertEquals(56789, $sut->getNotExportedDuration()); self::assertSame(56789, $sut->getNotExportedDuration());
self::assertEquals(6789, $sut->getNotExportedRate()); self::assertSame(6789.0, $sut->getNotExportedRate());
self::assertEquals(789, $sut->getRateTotal()); 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); $sut = $this->getSut(true);
$reports = $sut->getAvailableReports(new User()); $reports = $sut->getAvailableReports(new User());
self::assertIsArray($reports); self::assertIsArray($reports);
self::assertCount(5, $reports); self::assertCount(6, $reports);
} }
} }

View File

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

View File

@@ -76,4 +76,47 @@ class ThemeEventExtensionTest extends TestCase
$values = $sut->getJavascriptTranslations(); $values = $sut->getJavascriptTranslations();
self::assertCount(24, $values); 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 = [ $expected = [
'trigger', 'trigger',
'actions', 'actions',
'progressbar_color',
'javascript_translations', 'javascript_translations',
'active_timesheets', 'active_timesheets',
'encore_entry_css_source', 'encore_entry_css_source',

View File

@@ -109,9 +109,21 @@ class DailyWorkingTimeChartTest extends TestCase
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock(); $repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) use ($activity, $project) { $repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) use ($activity, $project) {
return [ 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

@@ -1190,6 +1190,14 @@
<source>label.not_invoiced</source> <source>label.not_invoiced</source>
<target>Nicht abgerechnet</target> <target>Nicht abgerechnet</target>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -1209,6 +1209,14 @@
<source>label.not_invoiced</source> <source>label.not_invoiced</source>
<target>Not billed</target> <target>Not billed</target>
</trans-unit> </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> </body>
</file> </file>
</xliff> </xliff>

View File

@@ -22,6 +22,10 @@
<source>report_project_view</source> <source>report_project_view</source>
<target>Projektübersicht</target> <target>Projektübersicht</target>
</trans-unit> </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"> <trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source> <source>reporting.initial_view</source>
<target>Initialer Bericht</target> <target>Initialer Bericht</target>

View File

@@ -22,6 +22,10 @@
<source>report_project_view</source> <source>report_project_view</source>
<target>Project overview</target> <target>Project overview</target>
</trans-unit> </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"> <trans-unit id="reporting.initial_view">
<source>reporting.initial_view</source> <source>reporting.initial_view</source>
<target>Initial report</target> <target>Initial report</target>