billable timesheets, inactive projects report, bookmark export search (#2503)
This commit is contained in:
@@ -41,9 +41,13 @@ export default class KimaiActiveRecords extends KimaiPlugin {
|
||||
const handle = function() { self.reloadActiveRecords(); };
|
||||
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
document.addEventListener('kimai.timesheetDelete', handle);
|
||||
document.addEventListener('kimai.activityUpdate', handle);
|
||||
document.addEventListener('kimai.activityDelete', handle);
|
||||
document.addEventListener('kimai.projectUpdate', handle);
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
}
|
||||
|
||||
emptyList() {
|
||||
|
||||
@@ -42,9 +42,13 @@ export default class KimaiRecentActivities extends KimaiPlugin {
|
||||
|
||||
document.addEventListener('kimai.recentActivities', handle);
|
||||
document.addEventListener('kimai.timesheetUpdate', handle);
|
||||
document.addEventListener('kimai.timesheetDelete', handle);
|
||||
document.addEventListener('kimai.activityUpdate', handle);
|
||||
document.addEventListener('kimai.activityDelete', handle);
|
||||
document.addEventListener('kimai.projectUpdate', handle);
|
||||
document.addEventListener('kimai.projectDelete', handle);
|
||||
document.addEventListener('kimai.customerUpdate', handle);
|
||||
document.addEventListener('kimai.customerDelete', handle);
|
||||
}
|
||||
|
||||
emptyList() {
|
||||
|
||||
@@ -42,6 +42,10 @@ export default class KimaiReducedClickHandler extends KimaiPlugin {
|
||||
href = jQuery(this).attr('href');
|
||||
}
|
||||
|
||||
if (href === undefined || href === null || href === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
callback(href);
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
"build/runtime.098eaae1.js",
|
||||
"build/0.79dbdbb9.js",
|
||||
"build/1.32489d92.js",
|
||||
"build/app.cc583d15.js"
|
||||
"build/app.38660438.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.bffbc2eb.css"
|
||||
@@ -53,7 +53,7 @@
|
||||
"build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
|
||||
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
|
||||
"build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP",
|
||||
"build/app.cc583d15.js": "sha384-Hotpowsx1ghAlRDochFNc750cMjhD3nx/GnA/lYYjE6KKU1OesPXhbkQMrqfoUUF",
|
||||
"build/app.38660438.js": "sha384-YsPmoVY6JNGaTWrQrdpePjMTA5lIi1JrL21aiU6Ullhkwt0aBz59ZTn70HTKd8yC",
|
||||
"build/app.bffbc2eb.css": "sha384-Wt0GyrHU40HyNqgnwmxa18+VreRdfpgpOcHFB3DSCHLdGpH5sM5jCTjsYSrtrdgj",
|
||||
"build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
|
||||
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"build/1.32489d92.js": "build/1.32489d92.js",
|
||||
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
|
||||
"build/app.css": "build/app.bffbc2eb.css",
|
||||
"build/app.js": "build/app.cc583d15.js",
|
||||
"build/app.js": "build/app.38660438.js",
|
||||
"build/calendar.css": "build/calendar.1408f57e.css",
|
||||
"build/calendar.js": "build/calendar.070aab88.js",
|
||||
"build/chart.js": "build/chart.34d60a88.js",
|
||||
|
||||
@@ -304,7 +304,9 @@ final class KimaiImporterCommand extends Command
|
||||
$validationMessages = [];
|
||||
try {
|
||||
$usedEmails = [];
|
||||
$userIds = [];
|
||||
foreach ($users as $oldUser) {
|
||||
$userIds[] = $oldUser['userID'];
|
||||
if (empty($oldUser['mail'])) {
|
||||
$validationMessages[] = sprintf('User "%s" with ID %s has no email', $oldUser['name'], $oldUser['userID']);
|
||||
continue;
|
||||
@@ -325,6 +327,15 @@ final class KimaiImporterCommand extends Command
|
||||
$validationMessages[] = sprintf('Project "%s" with ID %s has unknown customer with ID %s', $oldProject['name'], $oldProject['projectID'], $oldProject['customerID']);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($rates as $oldRate) {
|
||||
if ($oldRate['userID'] === null) {
|
||||
continue;
|
||||
}
|
||||
if (!\in_array($oldRate['userID'], $userIds)) {
|
||||
$validationMessages[] = sprintf('Unknown user with ID "%s" found for rate with project "%s" and activity "%s"', $oldRate['userID'], $oldRate['projectID'], $oldRate['activityID']);
|
||||
}
|
||||
}
|
||||
} catch (Exception $ex) {
|
||||
$validationMessages[] = $ex->getMessage();
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
// apply bookmark ONLY if search form was not submitted manually
|
||||
if ($bookmark !== null && !$request->query->has('performSearch')) {
|
||||
$data->setBookmark($bookmark);
|
||||
$submitData = array_merge($bookmark->getContent(), $submitData);
|
||||
if (!$request->query->has('setDefaultQuery')) {
|
||||
$submitData = array_merge($bookmark->getContent(), $submitData);
|
||||
}
|
||||
}
|
||||
|
||||
// clean up parameters from unknown search values
|
||||
|
||||
@@ -311,7 +311,7 @@ final class ActivityController extends AbstractController
|
||||
|
||||
$deleteForm = $this->createFormBuilder(null, [
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.activityUpdate kimai.activityDelete',
|
||||
'data-form-event' => 'kimai.activityDelete',
|
||||
'data-msg-success' => 'action.delete.success',
|
||||
'data-msg-error' => 'action.delete.error',
|
||||
]
|
||||
|
||||
@@ -376,7 +376,7 @@ final class CustomerController extends AbstractController
|
||||
|
||||
$deleteForm = $this->createFormBuilder(null, [
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.customerUpdate kimai.customerDelete',
|
||||
'data-form-event' => 'kimai.customerDelete',
|
||||
'data-msg-success' => 'action.delete.success',
|
||||
'data-msg-error' => 'action.delete.error',
|
||||
]
|
||||
|
||||
@@ -15,7 +15,6 @@ use App\Form\Toolbar\ExportToolbarForm;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\SubmitButton;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -50,26 +49,40 @@ class ExportController extends AbstractController
|
||||
$entries = [];
|
||||
|
||||
$form = $this->getToolbarForm($query, 'GET');
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
if ($this->handleSearch($form, $request)) {
|
||||
return $this->redirectToRoute('export');
|
||||
}
|
||||
|
||||
if ($form->isValid()) {
|
||||
/** @var SubmitButton $previewButton */
|
||||
$previewButton = $form->get('preview');
|
||||
if ($previewButton->isClicked()) {
|
||||
$showPreview = true;
|
||||
$query->setPageSize($maxItemsPreview);
|
||||
$entries = $this->getEntries($query);
|
||||
$byCustomer = [];
|
||||
|
||||
if ($form->isValid() && ($query->hasBookmark() || $request->query->has('performSearch'))) {
|
||||
$showPreview = true;
|
||||
$entries = $this->getEntries($query);
|
||||
foreach ($entries as $entry) {
|
||||
$cid = $entry->getProject()->getCustomer()->getId();
|
||||
if (!isset($byCustomer[$cid])) {
|
||||
$byCustomer[$cid] = [
|
||||
'customer' => $entry->getProject()->getCustomer(),
|
||||
'rate' => 0,
|
||||
'internalRate' => 0,
|
||||
'duration' => 0,
|
||||
];
|
||||
}
|
||||
$byCustomer[$cid]['rate'] += $entry->getRate();
|
||||
$byCustomer[$cid]['internalRate'] += $entry->getInternalRate();
|
||||
$byCustomer[$cid]['duration'] += $entry->getDuration();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('export/index.html.twig', [
|
||||
'by_customer' => $byCustomer,
|
||||
'query' => $query,
|
||||
'entries' => $entries,
|
||||
'form' => $form->createView(),
|
||||
'renderer' => $this->export->getRenderer(),
|
||||
'preview_max' => $maxItemsPreview,
|
||||
'preview_limit' => $maxItemsPreview,
|
||||
'preview_show' => $showPreview,
|
||||
'decimal' => $this->getUser()->isExportDecimal(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -420,7 +420,7 @@ final class ProjectController extends AbstractController
|
||||
|
||||
$deleteForm = $this->createFormBuilder(null, [
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.projectUpdate kimai.projectDelete',
|
||||
'data-form-event' => 'kimai.projectDelete',
|
||||
'data-msg-success' => 'action.delete.success',
|
||||
'data-msg-error' => 'action.delete.error',
|
||||
]
|
||||
|
||||
57
src/Controller/Reporting/InactiveProjectController.php
Normal file
57
src/Controller/Reporting/InactiveProjectController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Controller\AbstractController;
|
||||
use App\Reporting\ProjectStatisticService;
|
||||
use App\Reporting\ProjectView\ProjectViewForm;
|
||||
use App\Reporting\ProjectView\ProjectViewQuery;
|
||||
use App\Reporting\ProjectView\ProjectViewService;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
@@ -23,17 +23,17 @@ final class ProjectViewController extends AbstractController
|
||||
* @Route(path="/reporting/project_view", name="report_project_view", methods={"GET","POST"})
|
||||
* @Security("is_granted('view_reporting') and is_granted('budget_project')")
|
||||
*/
|
||||
public function __invoke(Request $request, ProjectViewService $service)
|
||||
public function __invoke(Request $request, ProjectStatisticService $service)
|
||||
{
|
||||
$query = new ProjectViewQuery($this->getDateTimeFactory()->createDateTime(), $this->getUser());
|
||||
|
||||
$form = $this->createForm(ProjectViewForm::class, $query, [
|
||||
'action' => $this->generateUrl('report_project_view')
|
||||
]);
|
||||
$dateFactory = $this->getDateTimeFactory();
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectViewQuery($dateFactory->createDateTime(), $user);
|
||||
$form = $this->createForm(ProjectViewForm::class, $query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
$entries = $service->getProjectView($query);
|
||||
$projects = $service->findProjectsForView($query);
|
||||
$entries = $service->getProjectView($user, $projects, $query->getToday());
|
||||
|
||||
$byCustomer = [];
|
||||
foreach ($entries as $entry) {
|
||||
@@ -47,6 +47,9 @@ final class ProjectViewController extends AbstractController
|
||||
return $this->render('reporting/project_view.html.twig', [
|
||||
'entries' => $byCustomer,
|
||||
'form' => $form->createView(),
|
||||
'title' => 'report_project_view',
|
||||
'tableName' => 'project_view_reporting',
|
||||
'now' => $this->getDateTimeFactory()->createDateTime(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
|
||||
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
|
||||
public const MIN_MINUTES_PER_ENTRY = 15;
|
||||
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
|
||||
public const MAX_DESCRIPTION_LENGTH = 500;
|
||||
public const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
public const ADD_TAGS_MAX_ENTRIES = 10000;
|
||||
public const MAX_TAG_PER_ENTRY = 3;
|
||||
|
||||
@@ -241,7 +241,7 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Entity"})
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @ORM\Column(name="exported", type="boolean", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
@@ -250,6 +250,9 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
|
||||
/**
|
||||
* @var bool
|
||||
*
|
||||
* @Serializer\Expose()
|
||||
* @Serializer\Groups({"Default"})
|
||||
*
|
||||
* @ORM\Column(name="billable", type="boolean", nullable=false, options={"default": true})
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
|
||||
@@ -380,6 +380,11 @@ class User extends BaseUser implements UserInterface
|
||||
return $this->getPreferenceValue('theme.layout', 'fixed') === 'boxed';
|
||||
}
|
||||
|
||||
public function isExportDecimal(): bool
|
||||
{
|
||||
return (bool) $this->getPreferenceValue('timesheet.export_decimal', false);
|
||||
}
|
||||
|
||||
public function setTimezone(?string $timezone)
|
||||
{
|
||||
if ($timezone === null) {
|
||||
|
||||
@@ -47,7 +47,17 @@ abstract class AbstractTimesheetSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete', $timesheet)) {
|
||||
$event->addAction('trash', ['url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]), 'class' => 'api-link', 'attr' => ['data-event' => 'kimai.timesheetDelete kimai.timesheetUpdate', 'data-method' => 'DELETE', 'data-question' => 'confirm.delete', 'data-msg-error' => 'action.delete.error', 'data-msg-success' => 'action.delete.success']]);
|
||||
$event->addAction('trash', [
|
||||
'url' => $this->path('delete_timesheet', ['id' => $timesheet->getId()]),
|
||||
'class' => 'api-link',
|
||||
'attr' => [
|
||||
'data-event' => 'kimai.timesheetDelete',
|
||||
'data-method' => 'DELETE',
|
||||
'data-question' => 'confirm.delete',
|
||||
'data-msg-error' => 'action.delete.error',
|
||||
'data-msg-success' => 'action.delete.success'
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
if ($activity->getId() === null) {
|
||||
return;
|
||||
}
|
||||
if ($this->isGranted('view', $activity)) {
|
||||
|
||||
if (!$event->isView('activity_details') && $this->isGranted('view', $activity)) {
|
||||
$event->addAction('details', ['url' => $this->path('activity_details', ['id' => $activity->getId()])]);
|
||||
}
|
||||
|
||||
@@ -68,7 +69,7 @@ class ActivitySubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('create-timesheet', ['icon' => 'start', 'url' => $this->path('admin_timesheet_create', $parameters), 'class' => 'modal-ajax-form']);
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete', $activity)) {
|
||||
if (($event->isIndexView() || $event->isView('project_details')) && $this->isGranted('delete', $activity)) {
|
||||
$event->addDelete($this->path('admin_activity_delete', ['id' => $activity->getId()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isGranted('view', $customer)) {
|
||||
if (!$event->isView('customer_details') && $this->isGranted('view', $customer)) {
|
||||
$event->addAction('details', ['url' => $this->path('customer_details', ['id' => $customer->getId()])]);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class CustomerSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete', $customer)) {
|
||||
$event->addAction('trash', ['url' => $this->path('admin_customer_delete', ['id' => $customer->getId()]), 'class' => 'modal-ajax-form text-red']);
|
||||
$event->addDelete($this->path('admin_customer_delete', ['id' => $customer->getId()]));
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_reporting') && $this->isGranted('budget_project')) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isGranted('view', $project)) {
|
||||
if (!$event->isView('project_details') && $this->isGranted('view', $project)) {
|
||||
$event->addAction('details', ['url' => $this->path('project_details', ['id' => $project->getId()])]);
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ class ProjectSubscriber extends AbstractActionsSubscriber
|
||||
$event->addAction('copy', ['url' => $this->path('admin_project_duplicate', ['id' => $project->getId()])]);
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete', $project)) {
|
||||
$event->addAction('trash', ['url' => $this->path('admin_project_delete', ['id' => $project->getId()]), 'class' => 'modal-ajax-form text-red']);
|
||||
if (($event->isIndexView() || $event->isView('customer_details')) && $this->isGranted('delete', $project)) {
|
||||
$event->addDelete($this->path('admin_project_delete', ['id' => $project->getId()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ class UserSubscriber extends AbstractActionsSubscriber
|
||||
}
|
||||
|
||||
if ($event->isIndexView() && $this->isGranted('delete', $user)) {
|
||||
$event->addAction('trash', ['url' => $this->path('admin_user_delete', ['id' => $user->getId()]), 'class' => 'modal-ajax-form']);
|
||||
$event->addDelete($this->path('admin_user_delete', ['id' => $user->getId()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ abstract class AbstractSpreadsheetRenderer
|
||||
'wrapText' => false,
|
||||
],
|
||||
'exported' => [],
|
||||
'billable' => [],
|
||||
'tags' => [],
|
||||
'hourlyRate' => [],
|
||||
'fixedRate' => [],
|
||||
@@ -122,7 +123,16 @@ abstract class AbstractSpreadsheetRenderer
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
|
||||
// TODO why is that format hardcoded and does not depend on the users locale?
|
||||
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
|
||||
}
|
||||
|
||||
@@ -134,7 +144,15 @@ abstract class AbstractSpreadsheetRenderer
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
|
||||
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
|
||||
}
|
||||
|
||||
@@ -146,7 +164,16 @@ abstract class AbstractSpreadsheetRenderer
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $excelDate);
|
||||
// TODO why is that format hardcoded and does not depend on the users locale?
|
||||
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
|
||||
}
|
||||
|
||||
@@ -338,7 +365,14 @@ abstract class AbstractSpreadsheetRenderer
|
||||
|
||||
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
|
||||
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
|
||||
$exported = $entity->isExported() ? 'entryState.exported' : 'entryState.not_exported';
|
||||
$exported = $entity->isExported() ? 'yes' : 'no';
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['billable']) && !isset($columns['billable']['render'])) {
|
||||
$columns['billable']['render'] = function (Worksheet $sheet, int $row, int $column, ExportItemInterface $entity) {
|
||||
$exported = (method_exists($entity, 'isBillable') && !$entity->isBillable()) ? 'no' : 'yes';
|
||||
$sheet->setCellValueByColumnAndRow($column, $row, $this->translator->trans($exported));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ class HtmlRenderer
|
||||
{
|
||||
$decimal = false;
|
||||
if (null !== $query->getCurrentUser()) {
|
||||
$decimal = (bool) $query->getCurrentUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
|
||||
$decimal = $query->getCurrentUser()->isExportDecimal();
|
||||
} elseif (null !== $query->getUser()) {
|
||||
$decimal = (bool) $query->getUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
|
||||
$decimal = $query->getUser()->isExportDecimal();
|
||||
}
|
||||
|
||||
return ['decimal' => $decimal];
|
||||
|
||||
@@ -64,9 +64,9 @@ class PDFRenderer
|
||||
{
|
||||
$decimal = false;
|
||||
if (null !== $query->getCurrentUser()) {
|
||||
$decimal = (bool) $query->getCurrentUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
|
||||
$decimal = $query->getCurrentUser()->isExportDecimal();
|
||||
} elseif (null !== $query->getUser()) {
|
||||
$decimal = (bool) $query->getUser()->getPreferenceValue('timesheet.export_decimal', $decimal);
|
||||
$decimal = $query->getUser()->isExportDecimal();
|
||||
}
|
||||
|
||||
return ['decimal' => $decimal];
|
||||
|
||||
@@ -21,6 +21,9 @@ interface ExportItemInterface extends InvoiceItemInterface
|
||||
*/
|
||||
public function isExported(): bool;
|
||||
|
||||
// will be activated with 2.0
|
||||
// public function isBillable(): bool;
|
||||
|
||||
/**
|
||||
* Returns the named meta field or null.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Form\Type\BillableType;
|
||||
use App\Form\Type\DateTimePickerType;
|
||||
use App\Form\Type\DescriptionType;
|
||||
use App\Form\Type\DurationType;
|
||||
@@ -128,6 +129,7 @@ class TimesheetEditForm extends AbstractType
|
||||
$builder->add('metaFields', MetaFieldsCollectionType::class);
|
||||
|
||||
$this->addExported($builder, $options);
|
||||
$this->addBillable($builder, $options);
|
||||
}
|
||||
|
||||
protected function showCustomer(array $options, bool $isNew, int $customerCount): bool
|
||||
@@ -264,6 +266,15 @@ class TimesheetEditForm extends AbstractType
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addBillable(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
if (!$options['include_billable']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$builder->add('billable', BillableType::class, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -276,6 +287,7 @@ class TimesheetEditForm extends AbstractType
|
||||
'csrf_token_id' => 'timesheet_edit',
|
||||
'include_user' => false,
|
||||
'include_exported' => false,
|
||||
'include_billable' => true,
|
||||
'include_rate' => true,
|
||||
'docu_chapter' => 'timesheet.html',
|
||||
'method' => 'POST',
|
||||
|
||||
@@ -12,7 +12,6 @@ namespace App\Form\Toolbar;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -27,6 +26,7 @@ class ExportToolbarForm extends AbstractToolbarForm
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addBillableChoice($builder);
|
||||
$this->addExportStateChoice($builder);
|
||||
$this->addTimesheetStateChoice($builder);
|
||||
if ($options['include_user']) {
|
||||
@@ -42,9 +42,6 @@ class ExportToolbarForm extends AbstractToolbarForm
|
||||
'label' => 'label.mark_as_exported',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('preview', SubmitType::class, [
|
||||
'label' => 'button.preview',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,6 +38,7 @@ class TimesheetToolbarForm extends AbstractToolbarForm
|
||||
$this->addUsersChoice($builder);
|
||||
}
|
||||
$this->addTimesheetStateChoice($builder);
|
||||
$this->addBillableChoice($builder);
|
||||
$this->addExportStateChoice($builder);
|
||||
$this->addPageSizeChoice($builder);
|
||||
$this->addHiddenPagination($builder);
|
||||
|
||||
@@ -30,9 +30,7 @@ interface InvoiceItemInterface
|
||||
public function getRate(): float;
|
||||
|
||||
// will be activated with 2.0
|
||||
/*
|
||||
public function getInternalRate(): ?float;
|
||||
*/
|
||||
// public function getInternalRate(): ?float;
|
||||
|
||||
public function getUser(): ?User;
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ class Day
|
||||
* @var int
|
||||
*/
|
||||
protected $totalDuration = 0;
|
||||
/**
|
||||
* @var int|null
|
||||
*/
|
||||
private $totalDurationBillable = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
@@ -54,6 +58,16 @@ class Day
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalDurationBillable(): int
|
||||
{
|
||||
return $this->totalDurationBillable;
|
||||
}
|
||||
|
||||
public function setTotalDurationBillable(int $seconds): void
|
||||
{
|
||||
$this->totalDurationBillable = $seconds;
|
||||
}
|
||||
|
||||
public function getTotalRate(): float
|
||||
{
|
||||
return $this->totalRate;
|
||||
|
||||
@@ -11,27 +11,14 @@ namespace App\Model\Statistic;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Monthly statistics
|
||||
*/
|
||||
class Month
|
||||
final class Month
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $month;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $totalDuration = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
protected $totalRate = 0.00;
|
||||
private $month;
|
||||
private $totalDuration = 0;
|
||||
private $totalRate = 0.00;
|
||||
private $billableDuration = 0;
|
||||
private $billableRate = 0.00;
|
||||
|
||||
/**
|
||||
* @param string $month
|
||||
*/
|
||||
public function __construct(string $month)
|
||||
{
|
||||
$monthNumber = (int) $month;
|
||||
@@ -43,14 +30,16 @@ class Month
|
||||
$this->month = $month;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getMonth()
|
||||
public function getMonth(): string
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function getMonthNumber(): int
|
||||
{
|
||||
return (int) $this->month;
|
||||
}
|
||||
|
||||
public function getTotalDuration(): int
|
||||
{
|
||||
return $this->totalDuration;
|
||||
@@ -74,4 +63,24 @@ class Month
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
return $this->billableDuration;
|
||||
}
|
||||
|
||||
public function setBillableDuration(int $billableDuration): void
|
||||
{
|
||||
$this->billableDuration = $billableDuration;
|
||||
}
|
||||
|
||||
public function getBillableRate(): float
|
||||
{
|
||||
return $this->billableRate;
|
||||
}
|
||||
|
||||
public function setBillableRate(float $billableRate): void
|
||||
{
|
||||
$this->billableRate = $billableRate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,25 +23,19 @@ class Year
|
||||
*/
|
||||
protected $months = [];
|
||||
|
||||
/**
|
||||
* @param string $year
|
||||
*/
|
||||
public function __construct($year)
|
||||
public function __construct(string $year)
|
||||
{
|
||||
$this->year = $year;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getYear()
|
||||
public function getYear(): string
|
||||
{
|
||||
return $this->year;
|
||||
}
|
||||
|
||||
public function setMonth(Month $month): Year
|
||||
{
|
||||
$this->months[(int) $month->getMonth()] = $month;
|
||||
$this->months[$month->getMonthNumber()] = $month;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -11,22 +11,13 @@ namespace App\Model;
|
||||
|
||||
class TimesheetCountedStatistic
|
||||
{
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $recordAmount = 0;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $recordDuration = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
protected $recordRate = 0.0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
protected $recordInternalRate = 0.0;
|
||||
private $recordAmount = 0;
|
||||
private $recordDuration = 0;
|
||||
private $recordRate = 0.0;
|
||||
private $recordInternalRate = 0.0;
|
||||
private $recordAmountBillable = 0;
|
||||
private $recordDurationBillable = 0;
|
||||
private $recordRateBillable = 0.0;
|
||||
|
||||
/**
|
||||
* Returns the total amount of included timesheet records.
|
||||
@@ -111,4 +102,34 @@ class TimesheetCountedStatistic
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRecordAmountBillable(): int
|
||||
{
|
||||
return $this->recordAmountBillable;
|
||||
}
|
||||
|
||||
public function setRecordAmountBillable(int $recordAmount): void
|
||||
{
|
||||
$this->recordAmountBillable = $recordAmount;
|
||||
}
|
||||
|
||||
public function getDurationBillable(): int
|
||||
{
|
||||
return $this->recordDurationBillable;
|
||||
}
|
||||
|
||||
public function setDurationBillable(int $recordDuration): void
|
||||
{
|
||||
$this->recordDurationBillable = $recordDuration;
|
||||
}
|
||||
|
||||
public function getRateBillable(): float
|
||||
{
|
||||
return $this->recordRateBillable;
|
||||
}
|
||||
|
||||
public function setRateBillable(float $recordRate): void
|
||||
{
|
||||
$this->recordRateBillable = $recordRate;
|
||||
}
|
||||
}
|
||||
|
||||
53
src/Reporting/ProjectInactive/ProjectInactiveForm.php
Normal file
53
src/Reporting/ProjectInactive/ProjectInactiveForm.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
46
src/Reporting/ProjectInactive/ProjectInactiveQuery.php
Normal file
46
src/Reporting/ProjectInactive/ProjectInactiveQuery.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,22 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Reporting\ProjectView;
|
||||
namespace App\Reporting;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Reporting\ProjectInactive\ProjectInactiveQuery;
|
||||
use App\Reporting\ProjectView\ProjectViewModel;
|
||||
use App\Reporting\ProjectView\ProjectViewQuery;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TimesheetRepository;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Exception;
|
||||
|
||||
final class ProjectViewService
|
||||
final class ProjectStatisticService
|
||||
{
|
||||
private $repository;
|
||||
private $timesheetRepository;
|
||||
@@ -28,28 +34,58 @@ final class ProjectViewService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectViewQuery $query
|
||||
* @return ProjectViewModel[]
|
||||
* @throws Exception
|
||||
* @param ProjectInactiveQuery $query
|
||||
* @return Project[]
|
||||
*/
|
||||
public function getProjectView(ProjectViewQuery $query): array
|
||||
public function findInactiveProjects(ProjectInactiveQuery $query): array
|
||||
{
|
||||
$factory = new DateTimeFactory($query->getToday()->getTimezone());
|
||||
$user = $query->getUser();
|
||||
$today = clone $query->getToday();
|
||||
$lastChange = clone $query->getLastChange();
|
||||
$now = new DateTime('now', $lastChange->getTimezone());
|
||||
|
||||
$begin = $factory->getStartOfWeek($today);
|
||||
$end = $factory->getEndOfWeek($today);
|
||||
$startMonth = (clone $begin)->modify('first day of this month');
|
||||
$endMonth = (clone $begin)->modify('last day of this month');
|
||||
$qb2 = $this->repository->createQueryBuilder('t1');
|
||||
$qb2
|
||||
->select('1')
|
||||
->from(Timesheet::class, 't')
|
||||
->andWhere('p = t.project')
|
||||
->andWhere($qb2->expr()->gte('t.begin', ':begin'))
|
||||
;
|
||||
|
||||
$qb = $this->repository->createQueryBuilder('p');
|
||||
$qb
|
||||
->select('p AS project')
|
||||
->addSelect('SUM(t.duration) AS totalDuration')
|
||||
->addSelect('SUM(t.rate) AS totalRate')
|
||||
->select('p, c')
|
||||
->leftJoin('p.customer', 'c')
|
||||
->andWhere($qb->expr()->eq('p.visible', true))
|
||||
->andWhere($qb->expr()->eq('c.visible', true))
|
||||
->andWhere($qb->expr()->not($qb->expr()->exists($qb2)))
|
||||
->andWhere(
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('p.end'),
|
||||
$qb->expr()->gte('p.end', ':project_end')
|
||||
)
|
||||
)
|
||||
->setParameter('project_end', $now, Types::DATETIME_MUTABLE)
|
||||
->setParameter('begin', $lastChange, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
$this->repository->addPermissionCriteria($qb, $user);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectViewQuery $query
|
||||
* @return Project[]
|
||||
*/
|
||||
public function findProjectsForView(ProjectViewQuery $query): array
|
||||
{
|
||||
$user = $query->getUser();
|
||||
$today = clone $query->getToday();
|
||||
|
||||
$qb = $this->repository->createQueryBuilder('p');
|
||||
$qb
|
||||
->select('p')
|
||||
->leftJoin('p.customer', 'c')
|
||||
->leftJoin(Timesheet::class, 't', 'WITH', 'p.id = t.project')
|
||||
->andWhere($qb->expr()->eq('p.visible', true))
|
||||
->andWhere($qb->expr()->eq('c.visible', true))
|
||||
->andWhere(
|
||||
@@ -59,7 +95,6 @@ final class ProjectViewService
|
||||
)
|
||||
)
|
||||
->addGroupBy('p')
|
||||
->addGroupBy('t.project')
|
||||
->setParameter('project_end', $today, Types::DATETIME_MUTABLE)
|
||||
;
|
||||
|
||||
@@ -69,7 +104,10 @@ final class ProjectViewService
|
||||
}
|
||||
|
||||
if (!$query->isIncludeNoWork()) {
|
||||
$qb->andHaving($qb->expr()->gt('totalDuration', 0));
|
||||
$qb
|
||||
->leftJoin(Timesheet::class, 't', 'WITH', 'p.id = t.project')
|
||||
->andHaving($qb->expr()->gt('SUM(t.duration)', 0))
|
||||
;
|
||||
}
|
||||
|
||||
if (!$query->isIncludeNoBudget()) {
|
||||
@@ -83,23 +121,59 @@ final class ProjectViewService
|
||||
|
||||
$this->repository->addPermissionCriteria($qb, $user);
|
||||
|
||||
$result = $qb->getQuery()->getResult();
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User $user
|
||||
* @param Project[] $projects
|
||||
* @param DateTime|null $today
|
||||
* @return ProjectViewModel[]
|
||||
*/
|
||||
public function getProjectView(User $user, array $projects, ?DateTime $today = null): array
|
||||
{
|
||||
$factory = new DateTimeFactory(new DateTimeZone($user->getTimezone()));
|
||||
if (null === $today) {
|
||||
$today = $factory->createDateTime();
|
||||
}
|
||||
|
||||
$today = clone $today;
|
||||
|
||||
$begin = $factory->getStartOfWeek($today);
|
||||
$end = $factory->getEndOfWeek($today);
|
||||
$startMonth = (clone $begin)->modify('first day of this month');
|
||||
$endMonth = (clone $begin)->modify('last day of this month');
|
||||
|
||||
$projectViews = [];
|
||||
foreach ($result as $res) {
|
||||
$entity = new ProjectViewModel($res['project']);
|
||||
$entity->setDurationTotal($res['totalDuration'] ?? 0);
|
||||
$entity->setRateTotal($res['totalRate'] ?? 0.00);
|
||||
|
||||
$projectViews[$entity->getProject()->getId()] = $entity;
|
||||
foreach ($projects as $project) {
|
||||
$projectViews[$project->getId()] = new ProjectViewModel($project);
|
||||
}
|
||||
|
||||
$projectIds = array_keys($projectViews);
|
||||
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, COUNT(t.id) as amount, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate, MAX(t.begin) as lastRecord')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->groupBy('t.project')
|
||||
->setParameter('project', array_values($projectIds))
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$projectViews[$row['id']]->setDurationTotal($row['duration']);
|
||||
$projectViews[$row['id']]->setRateTotal($row['rate']);
|
||||
$projectViews[$row['id']]->setTimesheetCounter($row['amount']);
|
||||
if ($row['lastRecord'] !== null) {
|
||||
// might be the wrong timezone
|
||||
$projectViews[$row['id']]->setLastRecord($factory->createDateTime($row['lastRecord']));
|
||||
}
|
||||
}
|
||||
|
||||
// values for today
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
|
||||
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('DATE(t.begin) = :starting_date')
|
||||
->groupBy('t.project')
|
||||
@@ -109,13 +183,13 @@ final class ProjectViewService
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$projectViews[$row['id']]->setDurationDay($row['duration']);
|
||||
$projectViews[$row['id']]->setDurationDay($row['duration'] ?? 0);
|
||||
}
|
||||
|
||||
// values for the current week
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
|
||||
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('DATE(t.begin) BETWEEN :start_date AND :end_date')
|
||||
->groupBy('t.project')
|
||||
@@ -132,7 +206,7 @@ final class ProjectViewService
|
||||
// values for the current month
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration')
|
||||
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('DATE(t.begin) BETWEEN :start_month AND :end_month')
|
||||
->groupBy('t.project')
|
||||
@@ -146,10 +220,10 @@ final class ProjectViewService
|
||||
$projectViews[$row['id']]->setDurationMonth($row['duration']);
|
||||
}
|
||||
|
||||
// values for the all time (not exported)
|
||||
// values for all time (not exported)
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
|
||||
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('t.exported = :exported')
|
||||
->groupBy('t.project')
|
||||
@@ -163,10 +237,10 @@ final class ProjectViewService
|
||||
$projectViews[$row['id']]->setNotExportedRate($row['rate']);
|
||||
}
|
||||
|
||||
// values for the all time (not exported and billable)
|
||||
// values for all time (not exported and billable)
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
|
||||
->select('IDENTITY(t.project) AS id, COALESCE(SUM(t.duration), 0) AS duration, COALESCE(SUM(t.rate), 0) AS rate')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('t.exported = :exported')
|
||||
->andWhere('t.billable = :billable')
|
||||
@@ -182,6 +256,23 @@ final class ProjectViewService
|
||||
$projectViews[$row['id']]->setNotBilledRate($row['rate']);
|
||||
}
|
||||
|
||||
// values for all time (none billable)
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
$qb
|
||||
->select('IDENTITY(t.project) AS id, SUM(t.duration) AS duration, SUM(t.rate) AS rate')
|
||||
->andWhere($qb->expr()->in('t.project', ':project'))
|
||||
->andWhere('t.billable = :billable')
|
||||
->groupBy('t.project')
|
||||
->setParameter('billable', true, Types::BOOLEAN)
|
||||
->setParameter('project', array_values($projectIds))
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getScalarResult();
|
||||
foreach ($result as $row) {
|
||||
$projectViews[$row['id']]->setBillableDuration($row['duration']);
|
||||
$projectViews[$row['id']]->setBillableRate($row['rate']);
|
||||
}
|
||||
|
||||
return array_values($projectViews);
|
||||
}
|
||||
}
|
||||
@@ -10,49 +10,24 @@
|
||||
namespace App\Reporting\ProjectView;
|
||||
|
||||
use App\Entity\Project;
|
||||
use DateTime;
|
||||
|
||||
final class ProjectViewModel
|
||||
{
|
||||
/**
|
||||
* @var Project
|
||||
*/
|
||||
private $project;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $timesheetCounter = 0;
|
||||
private $durationDay = 0;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $durationWeek = 0;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $durationMonth = 0;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $durationTotal = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
private $rateTotal = 0.00;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $notExportedDuration = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
private $notExportedRate = 0.00;
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
private $notBilledDuration = 0;
|
||||
/**
|
||||
* @var float
|
||||
*/
|
||||
private $notBilledRate = 0.00;
|
||||
private $billableDuration = 0;
|
||||
private $billableRate = 0.00;
|
||||
private $lastRecord;
|
||||
|
||||
public function __construct(Project $project)
|
||||
{
|
||||
@@ -64,6 +39,16 @@ final class ProjectViewModel
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function getTimesheetCounter(): int
|
||||
{
|
||||
return $this->timesheetCounter;
|
||||
}
|
||||
|
||||
public function setTimesheetCounter(int $timesheetCounter): void
|
||||
{
|
||||
$this->timesheetCounter = $timesheetCounter;
|
||||
}
|
||||
|
||||
public function getDurationDay(): int
|
||||
{
|
||||
return $this->durationDay;
|
||||
@@ -144,6 +129,26 @@ final class ProjectViewModel
|
||||
$this->notBilledRate = $notBilledRate;
|
||||
}
|
||||
|
||||
public function getBillableDuration(): int
|
||||
{
|
||||
return $this->billableDuration;
|
||||
}
|
||||
|
||||
public function setBillableDuration(int $billableDuration): void
|
||||
{
|
||||
$this->billableDuration = $billableDuration;
|
||||
}
|
||||
|
||||
public function getBillableRate(): float
|
||||
{
|
||||
return $this->billableRate;
|
||||
}
|
||||
|
||||
public function setBillableRate(float $billableRate): void
|
||||
{
|
||||
$this->billableRate = $billableRate;
|
||||
}
|
||||
|
||||
public function getRateTotal(): float
|
||||
{
|
||||
return $this->rateTotal;
|
||||
@@ -153,4 +158,14 @@ final class ProjectViewModel
|
||||
{
|
||||
$this->rateTotal = $rateTotal;
|
||||
}
|
||||
|
||||
public function getLastRecord(): ?DateTime
|
||||
{
|
||||
return $this->lastRecord;
|
||||
}
|
||||
|
||||
public function setLastRecord(DateTime $lastRecord): void
|
||||
{
|
||||
$this->lastRecord = $lastRecord;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ final class ReportingService
|
||||
}
|
||||
if ($this->security->isGranted('budget_project')) {
|
||||
$event->addReport(new Report('project_view', 'report_project_view', 'report_project_view', 'project'));
|
||||
$event->addReport(new Report('inactive_projects', 'report_project_inactive', 'report_inactive_project', 'project'));
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
@@ -97,9 +98,9 @@ class ActivityRepository extends EntityRepository
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('SUM(t.duration) as duration')
|
||||
->addSelect('SUM(t.rate) as rate')
|
||||
->addSelect('SUM(t.internalRate) as internal_rate')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
|
||||
->where('t.activity = :activity')
|
||||
->setParameter('activity', $activity)
|
||||
;
|
||||
@@ -115,6 +116,26 @@ class ActivityRepository extends EntityRepository
|
||||
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->where('t.activity = :activity')
|
||||
->andWhere('t.billable = :billable')
|
||||
->setParameter('activity', $activity)
|
||||
->setParameter('billable', true, Types::BOOLEAN)
|
||||
;
|
||||
|
||||
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if (null !== $timesheetResult) {
|
||||
$stats->setDurationBillable($timesheetResult['duration']);
|
||||
$stats->setRateBillable($timesheetResult['rate']);
|
||||
$stats->setRecordAmountBillable($timesheetResult['amount']);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
@@ -78,22 +79,16 @@ class CustomerRepository extends EntityRepository
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves statistics for one customer.
|
||||
*
|
||||
* @param Customer $customer
|
||||
* @return CustomerStatistic
|
||||
*/
|
||||
public function getCustomerStatistics(Customer $customer)
|
||||
public function getCustomerStatistics(Customer $customer): CustomerStatistic
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('SUM(t.duration) as duration')
|
||||
->addSelect('SUM(t.rate) as rate')
|
||||
->addSelect('SUM(t.internalRate) as internal_rate')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
|
||||
->andWhere('p.customer = :customer')
|
||||
->setParameter('customer', $customer)
|
||||
;
|
||||
@@ -109,6 +104,27 @@ class CustomerRepository extends EntityRepository
|
||||
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->join(Project::class, 'p', Query\Expr\Join::WITH, 't.project = p.id')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->andWhere('p.customer = :customer')
|
||||
->andWhere('t.billable = :billable')
|
||||
->setParameter('customer', $customer)
|
||||
->setParameter('billable', true, Types::BOOLEAN)
|
||||
;
|
||||
|
||||
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if (null !== $timesheetResult) {
|
||||
$stats->setDurationBillable($timesheetResult['duration']);
|
||||
$stats->setRateBillable($timesheetResult['rate']);
|
||||
$stats->setRecordAmountBillable($timesheetResult['amount']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->select('COUNT(a.id) as amount')
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\ProjectFormTypeQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use DateTime;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\ORMException;
|
||||
use Doctrine\ORM\Query;
|
||||
@@ -84,18 +85,14 @@ class ProjectRepository extends EntityRepository
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('SUM(t.duration) as duration')
|
||||
->addSelect('SUM(t.rate) as rate')
|
||||
->addSelect('SUM(t.internalRate) as internal_rate')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->addSelect('COALESCE(SUM(t.internalRate), 0) as internal_rate')
|
||||
->andWhere('t.project = :project')
|
||||
->setParameter('project', $project)
|
||||
;
|
||||
|
||||
if (null !== $begin) {
|
||||
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
|
||||
->setParameter('begin', $begin);
|
||||
}
|
||||
|
||||
// to calculate a budget at a certain point in time
|
||||
if (null !== $end) {
|
||||
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
|
||||
->setParameter('end', $end);
|
||||
@@ -112,6 +109,32 @@ class ProjectRepository extends EntityRepository
|
||||
$stats->setRecordInternalRate($timesheetResult['internal_rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->from(Timesheet::class, 't')
|
||||
->addSelect('COUNT(t.id) as amount')
|
||||
->addSelect('COALESCE(SUM(t.duration), 0) as duration')
|
||||
->addSelect('COALESCE(SUM(t.rate), 0) as rate')
|
||||
->andWhere('t.project = :project')
|
||||
->andWhere('t.billable = :billable')
|
||||
->setParameter('project', $project)
|
||||
->setParameter('billable', true, Types::BOOLEAN)
|
||||
;
|
||||
|
||||
// to calculate a budget at a certain point in time
|
||||
if (null !== $end) {
|
||||
$qb->andWhere($qb->expr()->lte('t.end', ':end'))
|
||||
->setParameter('end', $end);
|
||||
}
|
||||
|
||||
$timesheetResult = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if (null !== $timesheetResult) {
|
||||
$stats->setDurationBillable($timesheetResult['duration']);
|
||||
$stats->setRateBillable($timesheetResult['rate']);
|
||||
$stats->setRecordAmountBillable($timesheetResult['amount']);
|
||||
}
|
||||
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb
|
||||
->from(Activity::class, 'a')
|
||||
|
||||
@@ -304,6 +304,11 @@ class BaseQuery
|
||||
return $this->bookmark;
|
||||
}
|
||||
|
||||
public function hasBookmark(): bool
|
||||
{
|
||||
return null !== $this->bookmark;
|
||||
}
|
||||
|
||||
public function setName(string $name): void
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
@@ -245,10 +245,10 @@ class TimesheetRepository extends EntityRepository
|
||||
return $this->getDailyStats($user, $begin, $end);
|
||||
|
||||
case self::STATS_QUERY_DURATION:
|
||||
$what = 'SUM(t.duration)';
|
||||
$what = 'COALESCE(SUM(t.duration), 0)';
|
||||
break;
|
||||
case self::STATS_QUERY_RATE:
|
||||
$what = 'SUM(t.rate)';
|
||||
$what = 'COALESCE(SUM(t.rate), 0)';
|
||||
break;
|
||||
case self::STATS_QUERY_USER:
|
||||
$what = 'COUNT(DISTINCT(t.user))';
|
||||
@@ -271,10 +271,16 @@ class TimesheetRepository extends EntityRepository
|
||||
*/
|
||||
protected function queryThisMonth($select, User $user)
|
||||
{
|
||||
$begin = new DateTime('first day of this month 00:00:00');
|
||||
$end = new DateTime('last day of this month 23:59:59');
|
||||
try {
|
||||
$timezone = new \DateTimeZone($user->getTimezone());
|
||||
$begin = new DateTime('first day of this month 00:00:00', $timezone);
|
||||
$end = new DateTime('last day of this month 23:59:59', $timezone);
|
||||
|
||||
return $this->queryTimeRange($select, $begin, $end, $user);
|
||||
return $this->queryTimeRange($select, $begin, $end, $user);
|
||||
} catch (\Exception $ex) {
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -314,20 +320,13 @@ class TimesheetRepository extends EntityRepository
|
||||
return empty($result) ? 0 : $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch statistic data for one user.
|
||||
*
|
||||
* @param User $user
|
||||
* @return TimesheetStatistic
|
||||
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||
*/
|
||||
public function getUserStatistics(User $user)
|
||||
public function getUserStatistics(User $user): TimesheetStatistic
|
||||
{
|
||||
$durationTotal = $this->getStatistic(self::STATS_QUERY_DURATION, null, null, $user);
|
||||
$recordsTotal = $this->getStatistic(self::STATS_QUERY_AMOUNT, null, null, $user);
|
||||
$rateTotal = $this->getStatistic(self::STATS_QUERY_RATE, null, null, $user);
|
||||
$amountMonth = $this->queryThisMonth('SUM(t.rate)', $user);
|
||||
$durationMonth = $this->queryThisMonth('SUM(t.duration)', $user);
|
||||
$amountMonth = $this->queryThisMonth('COALESCE(SUM(t.rate), 0)', $user);
|
||||
$durationMonth = $this->queryThisMonth('COALESCE(SUM(t.duration), 0)', $user);
|
||||
$firstEntry = $this->getEntityManager()
|
||||
->createQuery('SELECT MIN(t.begin) FROM ' . Timesheet::class . ' t WHERE t.user = :user')
|
||||
->setParameter('user', $user)
|
||||
@@ -352,42 +351,15 @@ class TimesheetRepository extends EntityRepository
|
||||
* @param DateTime|null $end
|
||||
* @return Year[]
|
||||
*/
|
||||
public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null)
|
||||
public function getMonthlyStats(User $user = null, ?DateTime $begin = null, ?DateTime $end = null): array
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->select('SUM(t.rate) as rate, SUM(t.duration) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year')
|
||||
->from(Timesheet::class, 't')
|
||||
;
|
||||
|
||||
if (!empty($begin)) {
|
||||
$qb->andWhere($qb->expr()->gte('t.begin', ':from'))
|
||||
->setParameter('from', $begin);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNotNull('t.begin'));
|
||||
}
|
||||
|
||||
if (!empty($end)) {
|
||||
$qb->andWhere($qb->expr()->lte('t.end', ':to'))
|
||||
->setParameter('to', $end);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNotNull('t.end'));
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$qb->andWhere('t.user = :user')
|
||||
->setParameter('user', $user);
|
||||
}
|
||||
|
||||
$qb
|
||||
->orderBy('year', 'DESC')
|
||||
->addOrderBy('month', 'ASC')
|
||||
->groupBy('year')
|
||||
->addGroupBy('month');
|
||||
|
||||
/** @var Year[] $years */
|
||||
$years = [];
|
||||
|
||||
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, null);
|
||||
foreach ($qb->getQuery()->execute() as $statRow) {
|
||||
$curYear = $statRow['year'];
|
||||
$curMonth = (int) $statRow['month'];
|
||||
|
||||
if (!isset($years[$curYear])) {
|
||||
$year = new Year($curYear);
|
||||
@@ -398,15 +370,74 @@ class TimesheetRepository extends EntityRepository
|
||||
$years[$curYear] = $year;
|
||||
}
|
||||
|
||||
$month = new Month($statRow['month']);
|
||||
$month->setTotalDuration((int) $statRow['duration'])
|
||||
->setTotalRate((float) $statRow['rate']);
|
||||
$years[$curYear]->setMonth($month);
|
||||
$month = $years[$curYear]->getMonth($curMonth);
|
||||
$month->setTotalDuration((int) $statRow['duration']);
|
||||
$month->setTotalRate((float) $statRow['rate']);
|
||||
}
|
||||
|
||||
$qb = $this->getMonthlyStatsQuery($user, $begin, $end, true);
|
||||
foreach ($qb->getQuery()->execute() as $statRow) {
|
||||
$curYear = $statRow['year'];
|
||||
$curMonth = (int) $statRow['month'];
|
||||
|
||||
if (!isset($years[$curYear])) {
|
||||
$year = new Year($curYear);
|
||||
for ($i = 1; $i < 13; $i++) {
|
||||
$month = $i < 10 ? '0' . $i : (string) $i;
|
||||
$year->setMonth(new Month($month));
|
||||
}
|
||||
$years[$curYear] = $year;
|
||||
}
|
||||
|
||||
$month = $years[$curYear]->getMonth($curMonth);
|
||||
$month->setBillableDuration((int) $statRow['duration']);
|
||||
$month->setBillableRate((float) $statRow['rate']);
|
||||
}
|
||||
|
||||
return $years;
|
||||
}
|
||||
|
||||
private function getMonthlyStatsQuery(User $user = null, ?DateTime $begin = null, ?DateTime $end = null, ?bool $billable = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb->from(Timesheet::class, 't');
|
||||
$qb->select('COALESCE(SUM(t.rate), 0) as rate, COALESCE(SUM(t.duration), 0) as duration, MONTH(t.begin) as month, YEAR(t.begin) as year');
|
||||
|
||||
if (!empty($begin)) {
|
||||
$qb->andWhere($qb->expr()->gte('t.begin', ':from'));
|
||||
$qb->setParameter('from', $begin);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNotNull('t.begin'));
|
||||
}
|
||||
|
||||
if (!empty($end)) {
|
||||
$qb->andWhere($qb->expr()->lte('t.end', ':to'));
|
||||
$qb->setParameter('to', $end);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNotNull('t.end'));
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$qb->andWhere('t.user = :user');
|
||||
$qb->setParameter('user', $user);
|
||||
}
|
||||
|
||||
if (null !== $billable) {
|
||||
$qb->andWhere('t.billable = :billable');
|
||||
$qb->setParameter('billable', $billable);
|
||||
}
|
||||
|
||||
$qb
|
||||
->orderBy('year', 'DESC')
|
||||
->addOrderBy('month', 'ASC')
|
||||
->groupBy('year')
|
||||
->addGroupBy('month')
|
||||
;
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
/**
|
||||
* In case this method is called with one timezone and the results are from another timezone,
|
||||
* it might return rows outside the time-range.
|
||||
@@ -481,6 +512,7 @@ class TimesheetRepository extends EntityRepository
|
||||
$results[$dateKey] = [
|
||||
'rate' => 0,
|
||||
'duration' => 0,
|
||||
'billable' => 0, // duration
|
||||
'month' => $beginTmp->format('n'),
|
||||
'year' => $beginTmp->format('Y'),
|
||||
'day' => $beginTmp->format('j'),
|
||||
@@ -496,6 +528,9 @@ class TimesheetRepository extends EntityRepository
|
||||
|
||||
$results[$dateKey]['rate'] += $rate;
|
||||
$results[$dateKey]['duration'] += $duration;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['billable'] += $duration;
|
||||
}
|
||||
$detailsId =
|
||||
$result->getProject()->getCustomer()->getId()
|
||||
. '_' . $result->getProject()->getId()
|
||||
@@ -508,11 +543,15 @@ class TimesheetRepository extends EntityRepository
|
||||
'activity' => $result->getActivity(),
|
||||
'duration' => 0,
|
||||
'rate' => 0,
|
||||
'billable' => 0, // duration
|
||||
];
|
||||
}
|
||||
|
||||
$results[$dateKey]['details'][$detailsId]['duration'] += $duration;
|
||||
$results[$dateKey]['details'][$detailsId]['rate'] += $rate;
|
||||
if ($result->isBillable()) {
|
||||
$results[$dateKey]['details'][$detailsId]['billable'] += $duration;
|
||||
}
|
||||
}
|
||||
|
||||
$beginTmp = $newDateBegin;
|
||||
@@ -562,6 +601,7 @@ class TimesheetRepository extends EntityRepository
|
||||
$dateTime->setDate($statRow['year'], $statRow['month'], $statRow['day']);
|
||||
$dateTime->setTime(0, 0, 0);
|
||||
$day = new Day($dateTime, (int) $statRow['duration'], (float) $statRow['rate']);
|
||||
$day->setTotalDurationBillable($statRow['billable']);
|
||||
$day->setDetails($statRow['details']);
|
||||
$dateKey = $dateTime->format('Ymd');
|
||||
// make sure entries from other timezones are filtered
|
||||
|
||||
@@ -73,4 +73,26 @@ final class ThemeExtension implements RuntimeExtensionInterface
|
||||
|
||||
return $event->getTranslations();
|
||||
}
|
||||
|
||||
public function getProgressbarClass(float $percent, ?bool $reverseColors = false): string
|
||||
{
|
||||
$colors = ['xl' => 'progress-bar-danger', 'l' => 'progress-bar-warning', 'm' => 'progress-bar-success', 's' => 'progress-bar-primary', 'e' => 'progress-bar-info'];
|
||||
if (true === $reverseColors) {
|
||||
$colors = ['s' => 'progress-bar-danger', 'm' => 'progress-bar-warning', 'l' => 'progress-bar-success', 'xl' => 'progress-bar-primary', 'e' => 'progress-bar-info'];
|
||||
}
|
||||
|
||||
if ($percent > 90) {
|
||||
$class = $colors['xl'];
|
||||
} elseif ($percent > 70) {
|
||||
$class = $colors['l'];
|
||||
} elseif ($percent > 50) {
|
||||
$class = $colors['m'];
|
||||
} elseif ($percent > 30) {
|
||||
$class = $colors['s'];
|
||||
} else {
|
||||
$class = $colors['e'];
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class RuntimeExtensions extends AbstractExtension
|
||||
return [
|
||||
new TwigFunction('trigger', [ThemeExtension::class, 'trigger'], ['needs_environment' => true]),
|
||||
new TwigFunction('actions', [ThemeExtension::class, 'actions']),
|
||||
new TwigFunction('progressbar_color', [ThemeExtension::class, 'getProgressbarClass']),
|
||||
new TwigFunction('javascript_translations', [ThemeExtension::class, 'getJavascriptTranslations']),
|
||||
new TwigFunction('active_timesheets', [TimesheetExtension::class, 'activeEntries']),
|
||||
new TwigFunction('encore_entry_css_source', [EncoreExtension::class, 'getEncoreEntryCssSource']),
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro activity(activity, view, options) %}
|
||||
{% macro activity(activity, view, isTable) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% set event = actions(app.user, 'activity', view, {'activity': activity}) %}
|
||||
{% if view == 'index' or view == 'custom' %}
|
||||
{% if view == 'index' or view == 'custom' or isTable is not null %}
|
||||
{{ widgets.table_actions(event.actions) }}
|
||||
{% else %}
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% import "activity/actions.html.twig" as actions %}
|
||||
|
||||
{% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.activity(activity, 'details') }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.activity(activity, 'activity_details') }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% set can_edit = is_granted('edit', activity) %}
|
||||
@@ -42,7 +42,7 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if not activity.global %}
|
||||
<tr class="{{ widgets.class_customer_row(activity.project.customer, now) }}">
|
||||
<tr {{ widgets.customer_row_attr(activity.project.customer, now) }}>
|
||||
<th>{{ 'label.customer'|trans }}</th>
|
||||
<td>
|
||||
{{ widgets.label_customer(activity.project.customer) }}
|
||||
@@ -54,7 +54,7 @@
|
||||
{{ customerActions.customer(activity.project.customer, 'custom') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="{{ widgets.class_project_row(activity.project, now) }}">
|
||||
<tr {{ widgets.project_row_attr(activity.project, now) }}>
|
||||
<th>{{ 'label.project'|trans }}</th>
|
||||
<td>
|
||||
{{ widgets.label_project(activity.project) }}
|
||||
@@ -78,7 +78,11 @@
|
||||
{% endembed %}
|
||||
|
||||
{% if stats is not null %}
|
||||
{{ include('activity/embed_budget.html.twig', {'activity': activity, 'stats': stats}) }}
|
||||
{% set currency = null %}
|
||||
{% if activity.project is not null %}
|
||||
{% set currency = activity.project.customer.currency %}
|
||||
{% endif %}
|
||||
{{ include('embeds/budgets.html.twig', {'entity': activity, 'stats': stats, 'currency': currency}) }}
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
@@ -102,7 +106,7 @@
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.activityUpdate kimai.teamUpdate kimai.activityTeamUpdate kimai.projectTeamUpdate kimai.customerTeamUpdate kimai.projectUpdate kimai.rateUpdate');
|
||||
KimaiReloadPageWidget.create('kimai.activityUpdate kimai.teamUpdate kimai.activityTeamUpdate kimai.projectTeamUpdate kimai.customerTeamUpdate kimai.customerUpdate kimai.projectUpdate kimai.rateUpdate');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -37,16 +37,10 @@
|
||||
{% if entries.count == 0 %}
|
||||
{{ widgets.nothing_found('kimai.activityUpdate') }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate kimai.activityTeamUpdate'}) }}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate kimai.activityDelete kimai.activityTeamUpdate'}) }}
|
||||
|
||||
{% for entry in entries %}
|
||||
{% set class = widgets.class_activity_row(entry, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', entry) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('activity_details', {'id': entry.id}) %}
|
||||
{% endif %}
|
||||
<tr class="{{ class }}" data-href="{{ dataHref }}">
|
||||
<tr {{ widgets.activity_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('activity', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
|
||||
{# only none-global activities have a project and customer assigned #}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "customer/actions.html.twig" as actions %}
|
||||
|
||||
{% block page_actions %}{{ actions.customer(customer, 'details') }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.customer(customer, 'customer_details') }}{% endblock %}
|
||||
{% block page_title %}{{ 'admin_customer.title'|trans }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
@@ -121,7 +121,7 @@
|
||||
{{ render(controller('App\\Controller\\CustomerController::projectsAction', {'customer': customer.id, 'page': 1})) }}
|
||||
|
||||
{% if stats is not null %}
|
||||
{{ include('customer/embed_budget.html.twig', {'customer': customer, 'stats': stats}) }}
|
||||
{{ include('embeds/budgets.html.twig', {'entity': customer, 'stats': stats, 'currency': customer.currency}) }}
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
@@ -150,7 +150,7 @@
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.projectUpdate kimai.rateUpdate');
|
||||
KimaiReloadPageWidget.create('kimai.customerTeamUpdate kimai.customerUpdate kimai.teamUpdate kimai.projectTeamUpdate kimai.rateUpdate');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -3,7 +3,7 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'menu.admin_project'|trans }}{% endblock %}
|
||||
{% block box_attributes %}
|
||||
id="project_list_box" data-href="{{ path('customer_projects', {'id': customer.id}) }}" data-reload="kimai.projectUpdate"
|
||||
id="project_list_box" data-href="{{ path('customer_projects', {'id': customer.id}) }}" data-reload="kimai.projectUpdate kimai.projectDelete"
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if is_granted('view_reporting') and is_granted('budget_project') %}
|
||||
@@ -35,7 +35,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr class="{{ widgets.class_project_row(project, now) }}">
|
||||
<tr {{ widgets.project_row_attr(project, now) }}>
|
||||
<td>{{ widgets.label_project(project) }}</td>
|
||||
<td class="hidden-xs">{{ project.comment|comment1line(not app.user.smallLayout) }}</td>
|
||||
<td class="w-min text-center">
|
||||
@@ -47,7 +47,7 @@
|
||||
</td>
|
||||
<td class="w-min text-center">{{ widgets.label_visible(project.visible) }}</td>
|
||||
<td class="w-min text-center">{{ widgets.badge_team_access(project.teams) }}</td>
|
||||
<td class="actions">{{ actions.project(project, 'custom') }}</td>
|
||||
<td class="actions">{{ actions.project(project, 'customer_details', true) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -48,16 +48,10 @@
|
||||
{% if entries.count == 0 %}
|
||||
{{ widgets.nothing_found('kimai.customerUpdate kimai.customerTeamUpdate') }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerTeamUpdate'}) }}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerDelete kimai.customerTeamUpdate'}) }}
|
||||
|
||||
{% for entry in entries %}
|
||||
{% set class = widgets.class_customer_row(entry, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', entry) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('customer_details', {'id': entry.id}) %}
|
||||
{% endif %}
|
||||
<tr class="{{ class }}" data-href="{{ dataHref }}">
|
||||
<tr {{ widgets.customer_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('customer', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'number') }}">{{ entry.number }}</td>
|
||||
|
||||
23
templates/embeds/budgets.html.twig
Normal file
23
templates/embeds/budgets.html.twig
Normal 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 %}
|
||||
@@ -11,7 +11,7 @@
|
||||
'description': {'class': 'hidden-xs hidden-sm hidden-md timesheet-description', 'orderBy': false},
|
||||
'unit_price': {'class': 'hidden hidden-xs text-nowrap', 'orderBy': false},
|
||||
'duration': {'class': 'text-nowrap', 'orderBy': false},
|
||||
'rate_internal': {'class': 'hidden-xs text-nowrap', 'orderBy': false},
|
||||
'rate_internal': {'class': 'hidden text-nowrap', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-nowrap', 'orderBy': false},
|
||||
'exported': {'class': 'alwaysVisible', 'orderBy': false},
|
||||
} %}
|
||||
@@ -32,6 +32,7 @@
|
||||
{% block main %}
|
||||
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/search.html.twig" as search %}
|
||||
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
|
||||
{% block box_title %}{{ 'export.filter'|trans }}{% endblock %}
|
||||
{% block box_before %}{{ form_start(form) }}{% endblock %}
|
||||
@@ -46,43 +47,13 @@
|
||||
{% if form.users is defined %}
|
||||
{{ form_row(form.users) }}
|
||||
{% endif %}
|
||||
{{ form_row(form.billable) }}
|
||||
{{ form_row(form.exported) }}
|
||||
{{ form_row(form.state) }}
|
||||
{{ form_row(form.markAsExported) }}
|
||||
{% endblock %}
|
||||
{% block box_footer%}
|
||||
{% set buttons = {} %}
|
||||
{% for button in renderer %}
|
||||
{% set title = button.title %}
|
||||
{% set group = [] %}
|
||||
{% if buttons[(title)] is defined %}
|
||||
{% set group = buttons[(title)] %}
|
||||
{% endif %}
|
||||
{% set group = group|merge([button]) %}
|
||||
{% set buttons = buttons|merge({(title): group}) %}
|
||||
{% endfor %}
|
||||
<div class="btn-group" id="export-buttons" role="group">
|
||||
{% for group in buttons %}
|
||||
{% set button = group.0 %}
|
||||
{% if group|length == 1 %}
|
||||
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
|
||||
{{ ('button.' ~ button.title)|trans }}
|
||||
</button>
|
||||
{% elseif group|length > 1 %}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ ('button.' ~ button.title)|trans }} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% for button in group %}
|
||||
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ (button.id)|trans({}, 'export') }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{{ form_widget(form.preview) }}
|
||||
{{ search.searchButton(form) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}{{ form_end(form) }}{% endblock %}
|
||||
{% endembed %}
|
||||
@@ -91,28 +62,100 @@
|
||||
{% if entries is empty %}
|
||||
{{ widgets.nothing_found() }}
|
||||
{% else %}
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}
|
||||
{{ 'button.preview'|trans }}: {{ 'export.title'|trans }}
|
||||
{% endblock %}
|
||||
{% block box_body_class %}no-padding{% endblock %}
|
||||
{% block box_footer %}
|
||||
{% set buttons = {} %}
|
||||
{% for button in renderer %}
|
||||
{% set title = button.title %}
|
||||
{% set group = [] %}
|
||||
{% if buttons[(title)] is defined %}
|
||||
{% set group = buttons[(title)] %}
|
||||
{% endif %}
|
||||
{% set group = group|merge([button]) %}
|
||||
{% set buttons = buttons|merge({(title): group}) %}
|
||||
{% endfor %}
|
||||
<div class="btn-group" id="export-buttons" role="group">
|
||||
{% for group in buttons %}
|
||||
{% set button = group.0 %}
|
||||
{% if group|length == 1 %}
|
||||
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
|
||||
{{ ('button.' ~ button.title)|trans }}
|
||||
</button>
|
||||
{% elseif group|length > 1 %}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{ ('button.' ~ button.title)|trans }} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% for button in group %}
|
||||
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ (button.id)|trans({}, 'export') }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block box_body %}
|
||||
<table class="table table-hover dataTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'label.customer'|trans }}</th>
|
||||
<th class="w-min text-center hidden-xs">{{ 'label.duration'|trans }}</th>
|
||||
{# <th class="w-min text-right hidden-xs">{{ 'label.internalRate'|trans }}</th> #}
|
||||
<th class="w-min text-right">{{ 'label.total_rate'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in by_customer %}
|
||||
{% set currency = row.customer.currency %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ widgets.label_customer(row.customer) }}
|
||||
</td>
|
||||
<td class="w-min text-center hidden-xs">
|
||||
{{ row.duration|duration(decimal) }}
|
||||
</td>
|
||||
{#
|
||||
<td class="w-min text-right hidden-xs">
|
||||
{{ row.internalRate|money(currency) }}
|
||||
</td>
|
||||
#}
|
||||
<td class="w-min text-right">
|
||||
{{ row.rate|money(currency) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
{% set itemsAmount = entries|length %}
|
||||
{% if preview_limit %}
|
||||
{% set entries = entries|slice(0, preview_limit) %}
|
||||
{% endif %}
|
||||
|
||||
{{ tables.datatable_header(tableName, columns, query, {}) }}
|
||||
{% set totalAmount = {} %}
|
||||
{% set totalInternalAmount = {} %}
|
||||
{% set totalDuration = 0 %}
|
||||
{% for entry in entries %}
|
||||
{% set currency = entry.project.customer.currency %}
|
||||
{% set duration = entry.duration|duration %}
|
||||
{% if totalAmount[currency] is not defined %}
|
||||
{% set totalAmount = totalAmount|merge({(currency): 0}) %}
|
||||
{% set totalInternalAmount = totalInternalAmount|merge({(currency): 0}) %}
|
||||
{% endif %}
|
||||
{% if entry.fixedRate is not null %}
|
||||
{% set rate = entry.fixedRate %}
|
||||
{% else %}
|
||||
{% set rate = entry.hourlyRate %}
|
||||
{% endif %}
|
||||
{% set totalDuration = totalDuration + entry.duration %}
|
||||
{% set totalAmount = totalAmount|merge({(currency): totalAmount[currency] + entry.rate}) %}
|
||||
{% set totalInternalAmount = totalInternalAmount|merge({(currency): totalInternalAmount[currency] + entry.internalRate}) %}
|
||||
<tr>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">
|
||||
{{ entry.begin|date_short }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">
|
||||
{{ widgets.label_user(entry.user) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
|
||||
{{ widgets.label_project(entry.project) }}
|
||||
<br>
|
||||
@@ -130,7 +173,7 @@
|
||||
{{ rate|money(currency) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">
|
||||
{{ duration }}
|
||||
{{ entry.duration|duration(decimal) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}">
|
||||
{{ entry.internalRate|money(currency) }}
|
||||
@@ -161,30 +204,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'date') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">
|
||||
{{ totalDuration|duration }}
|
||||
</th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'rate_internal') }}">
|
||||
{% for currency, amount in totalInternalAmount %}
|
||||
{{ amount|money(currency) }}
|
||||
{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">
|
||||
{% for currency, amount in totalAmount %}
|
||||
{{ amount|money(currency) }}
|
||||
{% if not loop.last %}<br>{% endif %}
|
||||
{% endfor %}
|
||||
</th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}"></th>
|
||||
</tr>
|
||||
{% if preview_limit and itemsAmount > preview_limit %}
|
||||
<tr class="warning">
|
||||
<td colspan="10">» {{ 'preview.skipped_rows'|trans({'%rows%': (itemsAmount - preview_limit)}) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{{ tables.data_table_footer(entries) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -201,7 +225,7 @@
|
||||
var message = '{{ 'export.clear_all'|trans }}';
|
||||
var hasActive = true;
|
||||
|
||||
if (!$('#export-toggle-button').hasClass('export-off')) {
|
||||
if (!jQuery('#export-toggle-button').hasClass('export-off')) {
|
||||
message = '{{ 'export.mark_all'|trans }}';
|
||||
hasActive = false;
|
||||
}
|
||||
@@ -210,8 +234,8 @@
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
var btn = $(button);
|
||||
var exportButtons = $('.exportBtn');
|
||||
var btn = jQuery(button);
|
||||
var exportButtons = jQuery('.exportBtn');
|
||||
// disabling does not yet work...
|
||||
if (exportButtons.length > 0) {
|
||||
btn.addClass('disabled');
|
||||
@@ -219,8 +243,8 @@
|
||||
|
||||
// ... as the clicks are asynchronous and the each comes back too early ...
|
||||
exportButtons.each(function () {
|
||||
if (hasActive === $(this).hasClass('active')) {
|
||||
$(this).click();
|
||||
if (hasActive === jQuery(this).hasClass('active')) {
|
||||
jQuery(this).click();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -254,11 +278,11 @@
|
||||
} else {
|
||||
button.button('clean');
|
||||
}
|
||||
if ($('select#exported').val() !== '{{ constant('App\\Repository\\Query\\TimesheetQuery::STATE_ALL') }}') {
|
||||
if (jQuery('select#exported').val() !== '{{ constant('App\\Repository\\Query\\TimesheetQuery::STATE_ALL') }}') {
|
||||
button.closest('tr').hide('ease', function() {
|
||||
$(this).remove();
|
||||
if($(this).closest('table').find('tbody tr:visible').length === 0) {
|
||||
$('#export-buttons button').prop('disabled', true);
|
||||
jQuery(this).remove();
|
||||
if(jQuery(this).closest('table').find('tbody tr:visible').length === 0) {
|
||||
jQuery('#export-buttons button').prop('disabled', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -286,24 +310,24 @@
|
||||
}
|
||||
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
$('body').on('click', '.exportBtn', function() {
|
||||
var button = $(this);
|
||||
jQuery('body').on('click', '.exportBtn', function() {
|
||||
var button = jQuery(this);
|
||||
var id = button.attr('data-timesheet');
|
||||
updateTimesheetExportState(button, id, !button.hasClass('active'));
|
||||
});
|
||||
|
||||
$('#export-toggle-button').on('click', function () {
|
||||
jQuery('#export-toggle-button').on('click', function () {
|
||||
confirmToggleState(this);
|
||||
});
|
||||
|
||||
$('body').on('click', '#export-buttons .startExportBtn', function() {
|
||||
$('#renderer').val($(this).attr('data-type'));
|
||||
var $form = $("#export-form");
|
||||
jQuery('body').on('click', '#export-buttons .startExportBtn', function() {
|
||||
jQuery('#renderer').val(jQuery(this).attr('data-type'));
|
||||
var $form = jQuery("#export-form");
|
||||
var prevAction = $form.attr('action');
|
||||
var prevMethod = $form.attr('method');
|
||||
$form.attr('target', '_blank').attr('method', 'POST').attr('action', '{{ path('export_data') }}');
|
||||
$form.submit();
|
||||
$('#renderer').val('');
|
||||
jQuery('#renderer').val('');
|
||||
$form.removeAttr('target').attr('action', prevAction).attr('method', prevMethod);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{% endblock %}
|
||||
{% block box_body_class %}no-padding{% endblock %}
|
||||
{% block box_footer %}
|
||||
<a href="#" onclick="return saveAllInvoices(this);" class="btn btn-primary" id="create_all_invoices">
|
||||
<a href="#" onclick="return saveAllInvoices(this);" class="btn btn-success" id="create_all_invoices">
|
||||
{{ 'action.save_all'|trans }}
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -109,8 +109,8 @@
|
||||
</td>
|
||||
<td class="w-min text-center">
|
||||
{{ widgets.action_button('show', {'url': '#invoice_preview_details_' ~ model.customer.id, 'title': 'timesheet.all'|trans, 'class': 'btn btn-sm hidden-xs hidden-sm'}, 'link') }}
|
||||
{{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }}
|
||||
{{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'primary') }}
|
||||
{{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}) }}
|
||||
{{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }}
|
||||
</td>
|
||||
<td class="w-min text-center hidden-xs">
|
||||
{{ model.calculator.timeWorked|duration(isDecimal) }}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
{% macro progressbar(max, current, title, subTitle) %}
|
||||
{% macro progressbar(max, current, title, subTitle, reverseColors) %}
|
||||
{% set percentReached = 0 %}
|
||||
{% if max > 0 %}
|
||||
{% set percentReached = (current / (max / 100)) %}
|
||||
{% endif %}
|
||||
{% set class = "progress-bar-info" %}
|
||||
{% set width = percentReached|number_format(1, '.', '') %}
|
||||
|
||||
{% if percentReached > 90 %}
|
||||
{% set class = "progress-bar-danger" %}
|
||||
{% elseif percentReached > 70 %}
|
||||
{% set class = "progress-bar-warning" %}
|
||||
{% elseif percentReached > 50 %}
|
||||
{% set class = "progress-bar-success" %}
|
||||
{% elseif percentReached > 30 %}
|
||||
{% set class = "progress-bar-primary" %}
|
||||
{% endif %}
|
||||
|
||||
{% if width > 100 %}
|
||||
{% set width = 100 %}
|
||||
{% endif %}
|
||||
@@ -35,36 +24,25 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar {{ class }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
|
||||
<div class="progress-bar {{ progressbar_color(percentReached, reverseColors) }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro progressbar_small(max, current, leftValue, leftFormatted) %}
|
||||
{% macro progressbar_small(max, current, leftValue, leftFormatted, reverseColors) %}
|
||||
{% set percentReached = 0 %}
|
||||
{% if max > 0 %}
|
||||
{% set percentReached = (current / (max / 100)) %}
|
||||
{% endif %}
|
||||
{% set class = "progress-bar-info" %}
|
||||
{% set width = percentReached|number_format(1, '.', '') %}
|
||||
|
||||
{% if percentReached > 90 %}
|
||||
{% set class = "progress-bar-danger" %}
|
||||
{% elseif percentReached > 70 %}
|
||||
{% set class = "progress-bar-warning" %}
|
||||
{% elseif percentReached > 50 %}
|
||||
{% set class = "progress-bar-success" %}
|
||||
{% elseif percentReached > 30 %}
|
||||
{% set class = "progress-bar-primary" %}
|
||||
{% endif %}
|
||||
|
||||
{% if width > 100 %}
|
||||
{% set width = 100 %}
|
||||
{% endif %}
|
||||
|
||||
<div class="progress-group">
|
||||
<div class="progress progress-sm">
|
||||
<div class="progress-bar {{ class }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
|
||||
<div class="progress-bar {{ progressbar_color(percentReached, reverseColors) }}" role="progressbar" aria-valuenow="{{ width }}" aria-valuemin="0" aria-valuemax="100" style="width: {{ width }}%"></div>
|
||||
</div>
|
||||
{% if leftValue is not null and leftValue > 0 %}
|
||||
<small>{{ 'stats.percentUsedLeft'|trans({'%percent%': percentReached|number_format(0), '%left%': leftFormatted}) }}</small>
|
||||
|
||||
@@ -504,17 +504,105 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{# To be used mainly in <tr class="{{ class_customer_row(customer, now) }}"> #}
|
||||
{#
|
||||
To be used like this:
|
||||
<tr class="{{ class_customer_row(customer, now) }}">
|
||||
#}
|
||||
{% macro class_customer_row(customer, now) %}
|
||||
{%- if not customer.visible %}warning{% endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{# To be used mainly in <tr class="{{ class_project_row(project, now) }}"> #}
|
||||
{#
|
||||
To be used like this:
|
||||
<tr {{ customer_row_attr(customer, now) }}>
|
||||
#}
|
||||
{% macro customer_row_attr(customer, now) %}
|
||||
{%- set class = _self.class_customer_row(customer, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', customer) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('customer_details', {'id': customer.id}) %}
|
||||
{% endif -%}
|
||||
class="{{ class }}" data-href="{{ dataHref }}"
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
To be used like this:
|
||||
<tr class="{{ class_project_row(project, now) }}">
|
||||
#}
|
||||
{% macro class_project_row(project, now) %}
|
||||
{%- if not project.visible or (project.end is not null and project.end < now) %}warning{% endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{# To be used mainly in <tr class="{{ class_activity_row(activity, now) }}"> #}
|
||||
{#
|
||||
To be used like this:
|
||||
<tr {{ project_row_attr(project, now) }}>
|
||||
#}
|
||||
{% macro project_row_attr(project, now) %}
|
||||
{%- set class = _self.class_project_row(project, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', project) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('project_details', {'id': project.id}) %}
|
||||
{% endif -%}
|
||||
class="{{ class }}" data-href="{{ dataHref }}"
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
To be used like this:
|
||||
<tr {{ class_activity_row(activity, now) }}>
|
||||
#}
|
||||
{% macro class_activity_row(activity, now) %}
|
||||
{%- if not activity.visible %}warning{% endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
To be used like this:
|
||||
<tr {{ activity_row_attr(activity, now) }}>
|
||||
#}
|
||||
{% macro activity_row_attr(activity, now) %}
|
||||
{%- set class = _self.class_activity_row(activity, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', activity) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('activity_details', {'id': activity.id}) %}
|
||||
{% endif -%}
|
||||
class="{{ class }}" data-href="{{ dataHref }}"
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
To be used like this:
|
||||
<tr class="{{ class_user_row(user, now) }}">
|
||||
#}
|
||||
{% macro class_user_row(user) %}
|
||||
{%- if not user.enabled %}warning{% endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{#
|
||||
To be used like this:
|
||||
<tr {{ user_row_attr(user) }}>
|
||||
#}
|
||||
{% macro user_row_attr(user) %}
|
||||
{%- set class = _self.class_user_row(user) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', user) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('user_profile', {'username': user.username}) %}
|
||||
{% endif -%}
|
||||
class="{{ class }}" data-href="{{ dataHref }}"
|
||||
{% endmacro %}
|
||||
|
||||
{# To be used mainly in <tr class="{{ class_user_row(user, now) }}"> #}
|
||||
{% macro short_stats_row(stats) %}
|
||||
<div class="row">
|
||||
{% set colLength = 12 / stats|length %}
|
||||
{% for title, value in stats %}
|
||||
<div class="col-sm-{{ colLength }} border-right">
|
||||
<div class="description-block">
|
||||
<h5 class="description-header">{{ value }}</h5>
|
||||
<span class="description-text">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro project(project, view) %}
|
||||
{% macro project(project, view, isTable) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% set event = actions(app.user, 'project', view, {'project': project}) %}
|
||||
{% if view == 'index' or view == 'custom' %}
|
||||
{% if view == 'index' or view == 'custom' or isTable is not null %}
|
||||
{{ widgets.table_actions(event.actions) }}
|
||||
{% else %}
|
||||
{{ widgets.page_actions(event.actions) }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% import "project/actions.html.twig" as actions %}
|
||||
|
||||
{% block page_title %}{{ 'admin_project.title'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.project(project, 'details') }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.project(project, 'project_details') }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% set can_edit = is_granted('edit', project) %}
|
||||
@@ -36,7 +36,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="{{ widgets.class_customer_row(project.customer, now) }}">
|
||||
<tr {{ widgets.customer_row_attr(project.customer, now) }}>
|
||||
<th>{{ 'label.customer'|trans }}</th>
|
||||
<td>
|
||||
{{ widgets.label_customer(project.customer) }}
|
||||
@@ -99,7 +99,7 @@
|
||||
{{ render(controller('App\\Controller\\ProjectController::activitiesAction', {'project': project.id, 'page': 1})) }}
|
||||
|
||||
{% if stats is not null %}
|
||||
{{ include('project/embed_budget.html.twig', {'project': project, 'stats': stats}) }}
|
||||
{{ include('embeds/budgets.html.twig', {'entity': project, 'stats': stats, 'currency': project.customer.currency}) }}
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% block box_title %}{{ 'menu.admin_activity'|trans }}{% endblock %}
|
||||
{% block box_attributes %}
|
||||
id="activity_list_box" data-href="{{ path('project_activities', {'id': project.id}) }}" data-reload="kimai.activityUpdate"
|
||||
id="activity_list_box" data-href="{{ path('project_activities', {'id': project.id}) }}" data-reload="kimai.activityUpdate kimai.activityDelete"
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{% if project.visible and project.customer.visible and is_granted('create_activity') %}
|
||||
@@ -31,12 +31,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for activity in activities %}
|
||||
<tr class="{{ widgets.class_activity_row(activity, now) }}">
|
||||
<tr {{ widgets.activity_row_attr(activity, now) }}>
|
||||
<td>{{ widgets.label_activity(activity) }}</td>
|
||||
<td class="hidden-xs">{{ activity.comment|comment1line(not app.user.smallLayout) }}</td>
|
||||
<td class="w-min text-center">{{ widgets.label_visible(activity.visible) }}</td>
|
||||
<td class="w-min text-center">{{ widgets.badge_team_access(activity.teams) }}</td>
|
||||
<td class="actions">{{ actions.activity(activity, 'custom') }}</td>
|
||||
<td class="actions">{{ actions.activity(activity, 'project_details', true) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -41,16 +41,10 @@
|
||||
{% if entries.count == 0 %}
|
||||
{{ widgets.nothing_found('kimai.projectUpdate kimai.projectTeamUpdate') }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectTeamUpdate'}) }}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectDelete kimai.projectTeamUpdate'}) }}
|
||||
|
||||
{% for entry in entries %}
|
||||
{% set class = widgets.class_project_row(entry, now) %}
|
||||
{% set dataHref = '' %}
|
||||
{% if is_granted('view', entry) %}
|
||||
{% set class = class ~ ' alternative-link open-edit' %}
|
||||
{% set dataHref = path('project_details', {'id': entry.id}) %}
|
||||
{% endif %}
|
||||
<tr class="{{ class }}" data-href="{{ dataHref }}">
|
||||
<tr {{ widgets.project_row_attr(entry, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_color_dot('project', true, entry.name, null, entry.color) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment|comment1line }}</td>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{% extends 'reporting/layout.html.twig' %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
|
||||
{% block report_title %}{{ 'report_project_view'|trans({}, 'reporting') }}{% endblock %}
|
||||
{% block report_title %}{{ (title|default('report_project_view'))|trans({}, 'reporting') }}{% endblock %}
|
||||
|
||||
{% set columns = {
|
||||
'name': {'class': 'alwaysVisible'},
|
||||
'lastRecord': {'class': 'hidden text-center hw-min', 'title': 'label.last_record'|trans},
|
||||
'today': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationToday'|trans},
|
||||
'week': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationWeek'|trans},
|
||||
'month': {'class': 'hidden-md hidden-sm hidden-xs text-center hw-min', 'title': 'stats.durationMonth'|trans},
|
||||
@@ -17,7 +18,7 @@
|
||||
'comment': {'class': 'hidden-md hidden-sm hidden-xs hidden', 'title': 'label.comment'|trans},
|
||||
'actions': {'class': 'actions alwaysVisible'},
|
||||
} %}
|
||||
{% set tableName = 'project_view_reporting' %}
|
||||
{% set tableName = tableName|default('project_view_reporting') %}
|
||||
|
||||
{% block main_before %}
|
||||
{{ tables.data_table_column_modal(tableName, columns) }}
|
||||
@@ -32,7 +33,7 @@
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% import "project/actions.html.twig" as projectActions %}
|
||||
{% block box_body_class %}project-view-reporting-box {% if hasData %}no-padding{% endif %}{% endblock %}
|
||||
{% block box_body_class %}{{ tableName }}-box {% if hasData %}no-padding{% endif %}{% endblock %}
|
||||
{% block box_before %}
|
||||
{{ form_start(form, {'attr': {'class': 'form-inline form-reporting', 'id': 'project-view-form'}}) }}
|
||||
{% endblock %}
|
||||
@@ -40,7 +41,7 @@
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
{% block box_tools %}
|
||||
{{ widgets.action_button('visibility', {'modal': '#modal_project_view_reporting', 'class': 'btn-sm'}) }}
|
||||
{{ widgets.action_button('visibility', {'modal': ('#modal_' ~ tableName), 'class': 'btn-sm'}) }}
|
||||
{% endblock %}
|
||||
{% block box_title %}
|
||||
{{ form_widget(form) }}
|
||||
@@ -51,57 +52,64 @@
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, null, {'bordered': true, 'boxClass': ''}) }}
|
||||
|
||||
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
|
||||
<tr class="summary">
|
||||
<td colspan="11">{{ widgets.label_customer(mapping.customer) }}</td>
|
||||
{% for id, mapping in entries|sort((a, b) => a.customer.name <=> b.customer.name) %}
|
||||
<tr class="summary">
|
||||
<td colspan="{{ columns|length }}">{{ widgets.label_customer(mapping.customer) }}</td>
|
||||
</tr>
|
||||
{% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %}
|
||||
{% set project = entry.project %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% if is_granted('budget', project) %}
|
||||
<tr {{ widgets.project_row_attr(project, now) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_project(project) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'lastRecord') }}">
|
||||
{% if entry.lastRecord is not null %}
|
||||
{{ entry.lastRecord|date_short }}
|
||||
{% else %}
|
||||
–
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'today') }}">{{ entry.durationDay|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'week') }}">{{ entry.durationWeek|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'month') }}">{{ entry.durationMonth|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'durationTotal') }}">{{ entry.durationTotal|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
|
||||
{% if project.timeBudget > 0 %}
|
||||
{{ progress.progressbar(project.timeBudget, entry.billableDuration|default(0), entry.billableDuration|duration ~ ' / ' ~ project.timeBudget|duration, '') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
|
||||
{% if project.budget > 0 %}
|
||||
{{ progress.progressbar(project.budget, entry.billableRate|default(0), entry.billableRate|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}">
|
||||
{% if is_granted('create_export') %}
|
||||
<a href="{{ path('export', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': '', 'preview': true}) }}">
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateMoney') }}">
|
||||
{% if is_granted('view_invoice') %}
|
||||
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'projectEnd') }}">{% if project.end is not null %}{{ project.end|date_short }}{% endif %}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ project.comment }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
{{ projectActions.project(project, 'custom') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% for entry in mapping.projects|sort((a, b) => a.project.name <=> b.project.name) %}
|
||||
{% set project = entry.project %}
|
||||
{% set currency = project.customer.currency %}
|
||||
{% if is_granted('budget', project) %}
|
||||
<tr{% if is_granted('view', project) %} class="alternative-link open-edit" data-href="{{ path('project_details', {'id': project.id}) }}"{% endif %}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'name') }}">{{ widgets.label_project(project) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'today') }}">{{ entry.durationDay|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'week') }}">{{ entry.durationWeek|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'month') }}">{{ entry.durationMonth|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'durationTotal') }}">{{ entry.durationTotal|duration }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'timeBudget') }}">
|
||||
{% if project.timeBudget > 0 %}
|
||||
{{ progress.progressbar(project.timeBudget, entry.durationTotal|default(0), entry.durationTotal|duration ~ ' / ' ~ project.timeBudget|duration, '') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'budget') }}">
|
||||
{% if project.budget > 0 %}
|
||||
{{ progress.progressbar(project.budget, entry.rateTotal|default(0), entry.rateTotal|money(currency) ~ ' / ' ~ project.budget|money(currency), '') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateDuration') }}">
|
||||
{% if is_granted('create_export') %}
|
||||
<a href="{{ path('export', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': '', 'preview': true}) }}">
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'stateMoney') }}">
|
||||
{% if is_granted('view_invoice') %}
|
||||
<a href="{{ path('invoice', {'customers[]': project.customer.id, 'projects[]': project.id, 'daterange': ''}) }}">
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ entry.notBilledRate|money(currency) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'projectEnd') }}">{% if project.end is not null %}{{ project.end|date_short }}{% endif %}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ project.comment }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">
|
||||
{{ projectActions.project(project, 'custom') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{{ tables.data_table_footer(entries) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -88,6 +88,26 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.metaFields is defined and form.metaFields is not empty %}
|
||||
{% for meta in form.metaFields|sort((a, b) => a.vars.data.order <=> b.vars.data.order) %}
|
||||
{{ form_row(meta) }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if form.exported is defined or form.billable is defined %}
|
||||
{% set ebLength = (form.exported is defined and form.billable is defined) ? 6 : 12 %}
|
||||
<div class="row">
|
||||
{% if form.exported is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
{{ form_row(form.exported) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
{{ form_row(form.billable) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_widget(form) }}
|
||||
{% endblock %}
|
||||
{% block form_after %}
|
||||
|
||||
@@ -30,13 +30,6 @@
|
||||
'project': {'class': 'hidden-xs hidden-sm hidden-md'},
|
||||
'activity': {'class': 'hidden-xs hidden-sm'},
|
||||
'description': {'class': 'hidden-xs hidden-sm'},
|
||||
}) %}
|
||||
{% if canSeeUsername %}
|
||||
{% set columns = columns|merge({
|
||||
'username': {'class': 'hidden-xs', 'orderBy': false},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% set columns = columns|merge({
|
||||
'tags': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
|
||||
}) %}
|
||||
{% for field in metaColumns %}
|
||||
@@ -44,7 +37,13 @@
|
||||
('mf_' ~ field.name): {'title': field.label|trans, 'class': 'hidden-xs hidden-sm', 'orderBy': false}
|
||||
}) %}
|
||||
{% endfor %}
|
||||
{% if canSeeUsername %}
|
||||
{% set columns = columns|merge({
|
||||
'username': {'class': 'hidden-xs', 'orderBy': false},
|
||||
}) %}
|
||||
{% endif %}
|
||||
{% set columns = columns|merge({
|
||||
'billable': {'class': 'text-center hidden w-min', 'orderBy': false},
|
||||
'exported': {'class': 'text-center hidden w-min', 'orderBy': false},
|
||||
'actions': {'class': 'actions alwaysVisible'},
|
||||
}) %}
|
||||
@@ -60,7 +59,7 @@
|
||||
{% if entries.count == 0 %}
|
||||
{{ widgets.nothing_found('kimai.timesheetUpdate') }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.timesheetUpdate'}) }}
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.timesheetUpdate kimai.timesheetDelete'}) }}
|
||||
|
||||
{% set checkOverlappingDesc = false %}
|
||||
{% set checkOverlappingAsc = false %}
|
||||
@@ -150,19 +149,23 @@
|
||||
{{ entry.description|nl2br }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
|
||||
|
||||
{% for field in metaColumns %}
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
|
||||
{{ tables.datatable_meta_column(entry, field) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
{% if canSeeUsername %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}">
|
||||
{{ widgets.label_user(entry.user) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">{{ widgets.tag_list(entry.tags) }}</td>
|
||||
{% for field in metaColumns %}
|
||||
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
|
||||
{{ tables.datatable_meta_column(entry, field) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'billable') }}">
|
||||
{{ widgets.label_boolean(entry.billable) }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}">
|
||||
{{ widgets.label_boolean(entry.exported) }}
|
||||
</td>
|
||||
@@ -213,13 +216,15 @@
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}"></td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></td>
|
||||
{% if canSeeUsername %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}"></td>
|
||||
{% endif %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}"></td>
|
||||
{% for field in metaColumns %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}"></td>
|
||||
{% endfor %}
|
||||
{% if canSeeUsername %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'username') }}"></td>
|
||||
{% endif %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'billable') }}"></td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'exported') }}"></td>
|
||||
<td class="actions"></td>
|
||||
</tr>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}
|
||||
|
||||
{% for entry in entries %}
|
||||
<tr{% if is_granted('view', entry) %} class="open-edit alternative-link" data-href="{{ path('user_profile', {'username': entry.username}) }}"{% endif %}>
|
||||
<tr {{ widgets.user_row_attr(entry) }}>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'avatar') }}">{{ widgets.user_avatar(entry) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'alias') }}">{{ widgets.username(entry) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
|
||||
|
||||
@@ -53,8 +53,20 @@
|
||||
tooltips: {
|
||||
callbacks: {
|
||||
label: function(tooltipItem, data) {
|
||||
return data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
}
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return tooltipData['duration'];
|
||||
},
|
||||
afterTitle: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
beforeFooter: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
},
|
||||
footer: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return '{{ 'label.billable'|trans }}: ' + tooltipData['billable'];
|
||||
},
|
||||
}
|
||||
}
|
||||
{%- if user.enabled and is_granted('view_reporting') and (app.user.id == user.id or is_granted('view_other_timesheet')) -%}
|
||||
@@ -95,8 +107,9 @@
|
||||
],
|
||||
realData: [
|
||||
{% for month in yearStat.months %}
|
||||
'{{ month.totalDuration|duration }}'
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% set realDayData = {duration: month.totalDuration|duration, billable: month.billableDuration|duration} %}
|
||||
{{ realDayData|json_encode|raw }}
|
||||
{% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
],
|
||||
monthData: [
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
{%- for entry in day.details -%}
|
||||
{% set loopId = (entry.project.id ~ '_' ~ entry.activity.id) %}
|
||||
{%- if loopId == activityId -%}
|
||||
{% set realDayData = {duration: entry.duration|duration, project: entry.project.name, customer: entry.project.customer.name, activity: entry.activity.name, total: day.totalDuration|duration} %}
|
||||
{% set realDayData = {duration: entry.duration|duration, billable: day.totalDurationBillable|duration, project: entry.project.name, customer: entry.project.customer.name, activity: entry.activity.name, total: day.totalDuration|duration} %}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- if realDayData is not null -%}
|
||||
@@ -117,7 +117,7 @@
|
||||
footer: function(tooltipItems, data) {
|
||||
var tooltipItem = tooltipItems[0];
|
||||
var tooltipData = data.datasets[tooltipItem.datasetIndex].realData[tooltipItem.index];
|
||||
return '{{ 'stats.durationTotal'|trans }}: ' + tooltipData.total;
|
||||
return '{{ 'stats.durationTotal'|trans }}: ' + tooltipData.total + '\n{{ 'label.billable'|trans }}: ' + tooltipData.billable;
|
||||
},
|
||||
beforeFooter: function(tooltipItems, data) {
|
||||
return ' ';
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
<td style="width:50%">
|
||||
{% if project.timeBudget is not empty and project.timeBudget > 0 %}
|
||||
{% set budgetLeft = project.timeBudget - stats.recordDuration %}
|
||||
{{ progress.progressbar_small(project.timeBudget, stats.recordDuration, budgetLeft, budgetLeft|duration) }}
|
||||
{{ progress.progressbar_small(project.timeBudget, stats.durationBillable, budgetLeft, budgetLeft|duration) }}
|
||||
{% elseif project.budget is not empty and project.budget > 0 %}
|
||||
{% set budgetLeft = project.budget - stats.recordRate %}
|
||||
{{ progress.progressbar_small(project.budget, stats.recordRate, budgetLeft, budgetLeft|money(project.customer.currency)) }}
|
||||
{{ progress.progressbar_small(project.budget, stats.rateBillable, budgetLeft, budgetLeft|money(project.customer.currency)) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -483,6 +483,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
|
||||
'internalRate' => 'float',
|
||||
'exported' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'fixedRate' => '@float',
|
||||
'hourlyRate' => '@float',
|
||||
// TODO new fields: billable, category
|
||||
@@ -503,6 +504,7 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
|
||||
'internalRate' => 'float',
|
||||
'exported' => 'bool',
|
||||
'billable' => 'bool',
|
||||
'fixedRate' => '@float',
|
||||
'hourlyRate' => '@float',
|
||||
// TODO new fields: billable, category
|
||||
@@ -522,6 +524,8 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'user' => 'int',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
|
||||
'internalRate' => 'float',
|
||||
'exported' => 'bool',
|
||||
'billable' => 'bool',
|
||||
];
|
||||
|
||||
case 'TimesheetCollectionFull':
|
||||
@@ -538,6 +542,8 @@ abstract class APIControllerBaseTest extends ControllerBaseTest
|
||||
'user' => 'int',
|
||||
'metaFields' => ['result' => 'array', 'type' => 'TimesheetMeta'],
|
||||
'internalRate' => 'float',
|
||||
'exported' => 'bool',
|
||||
'billable' => 'bool',
|
||||
];
|
||||
|
||||
default:
|
||||
|
||||
@@ -139,6 +139,8 @@ class ActivityControllerTest extends ControllerBaseTest
|
||||
|
||||
$node = $client->getCrawler()->filter('div.box#activity_details_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#time_budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#activity_rates_box');
|
||||
|
||||
@@ -199,12 +199,12 @@ abstract class ControllerBaseTest extends WebTestCase
|
||||
|
||||
/**
|
||||
* @param HttpKernelBrowser $client
|
||||
* @param string $id
|
||||
* @param string $class
|
||||
* @param int $count
|
||||
*/
|
||||
protected function assertDataTableRowCount(HttpKernelBrowser $client, string $id, int $count)
|
||||
protected function assertDataTableRowCount(HttpKernelBrowser $client, string $class, int $count)
|
||||
{
|
||||
$node = $client->getCrawler()->filter('section.content div.' . $id . ' table.dataTable tbody tr:not(.summary)');
|
||||
$node = $client->getCrawler()->filter('section.content div.' . $class . ' table.dataTable tbody tr:not(.summary)');
|
||||
self::assertEquals($count, $node->count());
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ class CustomerControllerTest extends ControllerBaseTest
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#project_list_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#time_budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#team_listing_box');
|
||||
|
||||
@@ -34,7 +34,7 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
|
||||
$this->request($client, '/export/?preview=');
|
||||
$this->request($client, '/export/?performSearch=performSearch');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasNoEntriesWithFilter($client);
|
||||
@@ -83,13 +83,13 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
$this->importFixture($fixture);
|
||||
$em->flush();
|
||||
|
||||
$this->request($client, '/export/?preview=');
|
||||
$this->request($client, '/export/?performSearch=performSearch');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
// make sure all existing records are displayed
|
||||
$this->assertHasDataTable($client);
|
||||
// +1 row for summary
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 23);
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 22);
|
||||
|
||||
// assert export type buttons are available
|
||||
$expected = [
|
||||
@@ -113,7 +113,6 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||
$em = $this->getEntityManager();
|
||||
|
||||
$begin = new \DateTime('first day of this month');
|
||||
$user = $this->getUserByRole(User::ROLE_USER);
|
||||
@@ -127,10 +126,10 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
;
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/export/?preview=');
|
||||
$this->request($client, '/export/?performSearch=performSearch');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
// make sure all existing records are displayed
|
||||
// make sure existing records are not displayed
|
||||
$this->assertHasNoEntriesWithFilter($client);
|
||||
|
||||
$teamlead = $this->getUserByRole(User::ROLE_TEAMLEAD);
|
||||
@@ -143,13 +142,13 @@ class ExportControllerTest extends ControllerBaseTest
|
||||
;
|
||||
$this->importFixture($fixture);
|
||||
|
||||
$this->request($client, '/export/?preview=');
|
||||
$this->request($client, '/export/?performSearch=performSearch');
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
// make sure all existing records are displayed
|
||||
$this->assertHasDataTable($client);
|
||||
// +1 row for summary
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 3);
|
||||
$this->assertDataTableRowCount($client, 'datatable_export', 2);
|
||||
|
||||
// assert export type buttons are available
|
||||
$expected = [
|
||||
|
||||
@@ -148,6 +148,8 @@ class ProjectControllerTest extends ControllerBaseTest
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#activity_list_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#time_budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#budget_box');
|
||||
self::assertEquals(1, $node->count());
|
||||
$node = $client->getCrawler()->filter('div.box#team_listing_box');
|
||||
|
||||
60
tests/Controller/Reporting/InactiveProjectControllerTest.php
Normal file
60
tests/Controller/Reporting/InactiveProjectControllerTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,12 @@ use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
*/
|
||||
class ProjectViewControllerTest extends ControllerBaseTest
|
||||
{
|
||||
public function testProjectViewIsSecure()
|
||||
public function testReportIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/reporting/project_view');
|
||||
}
|
||||
|
||||
public function testProjectViewReport()
|
||||
public function testReport()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
|
||||
@@ -39,7 +39,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
|
||||
$projects->setCustomers($customers);
|
||||
$projects->setAmount(2);
|
||||
$projects->setIsVisible(true);
|
||||
$projects = $this->importFixture($projects);
|
||||
$this->importFixture($projects);
|
||||
|
||||
$activities = new ActivityFixtures();
|
||||
$activities->setAmount(5);
|
||||
@@ -53,7 +53,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
|
||||
$this->importFixture($timesheets);
|
||||
|
||||
$this->assertAccessIsGranted($client, '/reporting/project_view');
|
||||
self::assertStringContainsString('<div class="box-body project-view-reporting-box', $client->getResponse()->getContent());
|
||||
self::assertStringContainsString('<div class="box-body project_view_reporting-box', $client->getResponse()->getContent());
|
||||
$rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
|
||||
self::assertGreaterThan(0, $rows->count());
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class UserTest extends TestCase
|
||||
self::assertFalse($user->hasTeamAssignment());
|
||||
self::assertFalse($user->canSeeAllData());
|
||||
self::assertFalse($user->isSmallLayout());
|
||||
self::assertFalse($user->isExportDecimal());
|
||||
|
||||
$user->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y');
|
||||
self::assertEquals('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', $user->getAvatar());
|
||||
@@ -114,6 +115,9 @@ class UserTest extends TestCase
|
||||
self::assertTrue($user->isSmallLayout());
|
||||
$user->setPreferenceValue('theme.layout', '12345');
|
||||
self::assertFalse($user->isSmallLayout());
|
||||
|
||||
$user->setPreferenceValue('timesheet.export_decimal', true);
|
||||
self::assertTrue($user->isExportDecimal());
|
||||
}
|
||||
|
||||
public function testDisplayName()
|
||||
|
||||
@@ -91,25 +91,26 @@ class CsvRendererTest extends AbstractRendererTest
|
||||
10 => 'activity description',
|
||||
11 => '',
|
||||
12 => '',
|
||||
13 => 'foo,bar',
|
||||
14 => '',
|
||||
15 => ' EUR 84.00 ',
|
||||
16 => 'meta-bar',
|
||||
17 => 'meta-bar2',
|
||||
18 => 'customer-bar',
|
||||
19 => '',
|
||||
20 => 'project-foo2',
|
||||
21 => 'activity-bar',
|
||||
22 => 'timesheet',
|
||||
23 => 'work',
|
||||
24 => 'A-0123456789',
|
||||
25 => 'DE-9876543210',
|
||||
26 => 'ORDER-123',
|
||||
13 => '',
|
||||
14 => 'foo,bar',
|
||||
15 => '',
|
||||
16 => ' EUR 84.00 ',
|
||||
17 => 'meta-bar',
|
||||
18 => 'meta-bar2',
|
||||
19 => 'customer-bar',
|
||||
20 => '',
|
||||
21 => 'project-foo2',
|
||||
22 => 'activity-bar',
|
||||
23 => 'timesheet',
|
||||
24 => 'work',
|
||||
25 => 'A-0123456789',
|
||||
26 => 'DE-9876543210',
|
||||
27 => 'ORDER-123',
|
||||
];
|
||||
|
||||
self::assertEquals(7, \count($all));
|
||||
self::assertEquals($expected, $all[5]);
|
||||
self::assertEquals(\count($expected), \count($all[0]));
|
||||
self::assertEquals('foo', $all[4][13]);
|
||||
self::assertEquals('foo', $all[4][14]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,25 +88,26 @@ class CsvRendererTest extends AbstractRendererTest
|
||||
10 => 'activity description',
|
||||
11 => '',
|
||||
12 => '',
|
||||
13 => 'foo,bar',
|
||||
14 => '',
|
||||
15 => ' EUR 84.00 ',
|
||||
16 => 'meta-bar',
|
||||
17 => 'meta-bar2',
|
||||
18 => 'customer-bar',
|
||||
19 => '',
|
||||
20 => 'project-foo2',
|
||||
21 => 'activity-bar',
|
||||
22 => 'timesheet',
|
||||
23 => 'work',
|
||||
24 => 'A-0123456789',
|
||||
25 => 'DE-9876543210',
|
||||
26 => 'ORDER-123',
|
||||
13 => '',
|
||||
14 => 'foo,bar',
|
||||
15 => '',
|
||||
16 => ' EUR 84.00 ',
|
||||
17 => 'meta-bar',
|
||||
18 => 'meta-bar2',
|
||||
19 => 'customer-bar',
|
||||
20 => '',
|
||||
21 => 'project-foo2',
|
||||
22 => 'activity-bar',
|
||||
23 => 'timesheet',
|
||||
24 => 'work',
|
||||
25 => 'A-0123456789',
|
||||
26 => 'DE-9876543210',
|
||||
27 => 'ORDER-123',
|
||||
];
|
||||
|
||||
self::assertEquals(7, \count($all));
|
||||
self::assertEquals($expected, $all[5]);
|
||||
self::assertEquals(\count($expected), \count($all[0]));
|
||||
self::assertEquals('foo', $all[4][13]);
|
||||
self::assertEquals('foo', $all[4][14]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ class DayTest extends TestCase
|
||||
$date = new DateTime('-8 hours');
|
||||
$sut = new Day($date, 12340, 197.25956);
|
||||
|
||||
$this->assertSame($date, $sut->getDay());
|
||||
$this->assertEquals([], $sut->getDetails());
|
||||
$this->assertEquals(12340, $sut->getTotalDuration());
|
||||
$this->assertEquals(197.25956, $sut->getTotalRate());
|
||||
self::assertSame($date, $sut->getDay());
|
||||
self::assertEquals([], $sut->getDetails());
|
||||
self::assertSame(12340, $sut->getTotalDuration());
|
||||
self::assertSame(197.25956, $sut->getTotalRate());
|
||||
self::assertSame(0, $sut->getTotalDurationBillable());
|
||||
}
|
||||
|
||||
public function testAllowedMonths()
|
||||
@@ -36,9 +37,11 @@ class DayTest extends TestCase
|
||||
|
||||
$sut->setTotalDuration(999);
|
||||
$sut->setTotalRate(0.123456789);
|
||||
$sut->setTotalDurationBillable(12345);
|
||||
|
||||
$this->assertEquals(999, $sut->getTotalDuration());
|
||||
$this->assertEquals(0.123456789, $sut->getTotalRate());
|
||||
self::assertSame(999, $sut->getTotalDuration());
|
||||
self::assertSame(0.123456789, $sut->getTotalRate());
|
||||
self::assertSame(12345, $sut->getTotalDurationBillable());
|
||||
}
|
||||
|
||||
public function testSetDetails()
|
||||
@@ -47,6 +50,6 @@ class DayTest extends TestCase
|
||||
|
||||
$sut->setDetails(['foo' => ['bar' => '1212e'], 'hello' => 'world']);
|
||||
|
||||
$this->assertEquals(['foo' => ['bar' => '1212e'], 'hello' => 'world'], $sut->getDetails());
|
||||
self::assertEquals(['foo' => ['bar' => '1212e'], 'hello' => 'world'], $sut->getDetails());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,11 @@ class MonthTest extends TestCase
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Month('01');
|
||||
$this->assertEquals('01', $sut->getMonth());
|
||||
$this->assertEquals(0, $sut->getTotalDuration());
|
||||
$this->assertEquals(0, $sut->getTotalRate());
|
||||
self::assertSame('01', $sut->getMonth());
|
||||
self::assertSame(0, $sut->getTotalDuration());
|
||||
self::assertSame(0.0, $sut->getTotalRate());
|
||||
self::assertSame(0, $sut->getBillableDuration());
|
||||
self::assertSame(0.0, $sut->getBillableRate());
|
||||
}
|
||||
|
||||
public function testAllowedMonths()
|
||||
@@ -35,7 +37,7 @@ class MonthTest extends TestCase
|
||||
for ($i = 10; $i < 13; $i++) {
|
||||
new Month((string) $i);
|
||||
}
|
||||
$this->assertTrue(true);
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testInvalidMonths()
|
||||
@@ -47,8 +49,8 @@ class MonthTest extends TestCase
|
||||
} catch (Exception $e) {
|
||||
$ex = $e;
|
||||
}
|
||||
$this->assertInstanceOf(InvalidArgumentException::class, $ex);
|
||||
$this->assertEquals(
|
||||
self::assertInstanceOf(InvalidArgumentException::class, $ex);
|
||||
self::assertEquals(
|
||||
'Invalid month given. Expected 1-12, received "' . ((int) $month) . '".',
|
||||
$ex->getMessage()
|
||||
);
|
||||
@@ -60,8 +62,12 @@ class MonthTest extends TestCase
|
||||
$sut = new Month('01');
|
||||
$sut->setTotalDuration(999);
|
||||
$sut->setTotalRate(0.123456789);
|
||||
$sut->setBillableDuration(123456);
|
||||
$sut->setBillableRate(123.456789);
|
||||
|
||||
$this->assertEquals(999, $sut->getTotalDuration());
|
||||
$this->assertEquals(0.123456789, $sut->getTotalRate());
|
||||
self::assertSame(999, $sut->getTotalDuration());
|
||||
self::assertSame(0.123456789, $sut->getTotalRate());
|
||||
self::assertSame(123456, $sut->getBillableDuration());
|
||||
self::assertSame(123.456789, $sut->getBillableRate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ class YearTest extends TestCase
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Year('1999');
|
||||
$this->assertNull($sut->getMonth(1));
|
||||
$this->assertEmpty($sut->getMonths());
|
||||
$this->assertIsArray($sut->getMonths());
|
||||
$this->assertEquals('1999', $sut->getYear());
|
||||
self::assertNull($sut->getMonth(1));
|
||||
self::assertEmpty($sut->getMonths());
|
||||
self::assertIsArray($sut->getMonths());
|
||||
self::assertEquals('1999', $sut->getYear());
|
||||
}
|
||||
|
||||
public function testSetter()
|
||||
@@ -34,15 +34,15 @@ class YearTest extends TestCase
|
||||
$sut->setMonth(new Month('01'));
|
||||
$sut->setMonth(new Month('02'));
|
||||
$sut->setMonth(new Month('03'));
|
||||
$this->assertEquals(3, \count($sut->getMonths()));
|
||||
self::assertEquals(3, \count($sut->getMonths()));
|
||||
|
||||
$sut->setMonth(new Month('01'));
|
||||
|
||||
$this->assertEquals(3, \count($sut->getMonths()));
|
||||
self::assertEquals(3, \count($sut->getMonths()));
|
||||
|
||||
$this->assertInstanceOf(Month::class, $sut->getMonth(1));
|
||||
$this->assertInstanceOf(Month::class, $sut->getMonth(2));
|
||||
$this->assertInstanceOf(Month::class, $sut->getMonth(3));
|
||||
$this->assertNull($sut->getMonth(4));
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(1));
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(2));
|
||||
self::assertInstanceOf(Month::class, $sut->getMonth(3));
|
||||
self::assertNull($sut->getMonth(4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ class TimesheetCountedStatisticTest extends TestCase
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new TimesheetCountedStatistic();
|
||||
self::assertEquals(0.0, $sut->getRecordRate());
|
||||
self::assertEquals(0, $sut->getRecordDuration());
|
||||
self::assertEquals(0, $sut->getRecordAmount());
|
||||
self::assertEquals(0.0, $sut->getRecordInternalRate());
|
||||
self::assertSame(0.0, $sut->getRecordRate());
|
||||
self::assertSame(0, $sut->getRecordDuration());
|
||||
self::assertSame(0, $sut->getRecordAmount());
|
||||
self::assertSame(0.0, $sut->getRecordInternalRate());
|
||||
self::assertSame(0, $sut->getDurationBillable());
|
||||
self::assertSame(0.0, $sut->getRateBillable());
|
||||
self::assertSame(0, $sut->getRecordAmountBillable());
|
||||
}
|
||||
|
||||
public function testSetter()
|
||||
@@ -34,9 +37,17 @@ class TimesheetCountedStatisticTest extends TestCase
|
||||
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordAmount(5));
|
||||
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09));
|
||||
|
||||
self::assertEquals(23.97, $sut->getRecordRate());
|
||||
self::assertEquals(21, $sut->getRecordDuration());
|
||||
self::assertEquals(5, $sut->getRecordAmount());
|
||||
self::assertEquals(99.09, $sut->getRecordInternalRate());
|
||||
self::assertSame(23.97, $sut->getRecordRate());
|
||||
self::assertSame(21, $sut->getRecordDuration());
|
||||
self::assertSame(5, $sut->getRecordAmount());
|
||||
self::assertSame(99.09, $sut->getRecordInternalRate());
|
||||
|
||||
$sut->setRateBillable(123.456);
|
||||
$sut->setDurationBillable(1234);
|
||||
$sut->setRecordAmountBillable(4321);
|
||||
|
||||
self::assertSame(123.456, $sut->getRateBillable());
|
||||
self::assertSame(1234, $sut->getDurationBillable());
|
||||
self::assertSame(4321, $sut->getRecordAmountBillable());
|
||||
}
|
||||
}
|
||||
|
||||
42
tests/Reporting/ProjectInactive/ProjectInactiveQueryTest.php
Normal file
42
tests/Reporting/ProjectInactive/ProjectInactiveQueryTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,28 @@ class ProjectViewModelTest extends TestCase
|
||||
$sut = new ProjectViewModel($project);
|
||||
|
||||
self::assertSame($project, $sut->getProject());
|
||||
self::assertEquals(0, $sut->getDurationDay());
|
||||
self::assertEquals(0, $sut->getDurationMonth());
|
||||
self::assertEquals(0, $sut->getDurationTotal());
|
||||
self::assertEquals(0, $sut->getDurationWeek());
|
||||
self::assertEquals(0, $sut->getNotExportedDuration());
|
||||
self::assertEquals(0, $sut->getNotExportedRate());
|
||||
self::assertEquals(0, $sut->getRateTotal());
|
||||
self::assertSame(0, $sut->getDurationDay());
|
||||
self::assertSame(0, $sut->getDurationMonth());
|
||||
self::assertSame(0, $sut->getDurationTotal());
|
||||
self::assertSame(0, $sut->getDurationWeek());
|
||||
self::assertSame(0, $sut->getNotExportedDuration());
|
||||
self::assertSame(0.0, $sut->getNotExportedRate());
|
||||
self::assertSame(0.0, $sut->getRateTotal());
|
||||
self::assertNull($sut->getLastRecord());
|
||||
self::assertSame(0, $sut->getTimesheetCounter());
|
||||
self::assertNull($sut->getLastRecord());
|
||||
self::assertSame(0.0, $sut->getBillableRate());
|
||||
self::assertSame(0, $sut->getBillableDuration());
|
||||
self::assertSame(0, $sut->getNotBilledDuration());
|
||||
self::assertSame(0.0, $sut->getNotBilledRate());
|
||||
}
|
||||
|
||||
public function testSetterGetter()
|
||||
{
|
||||
$sut = new ProjectViewModel(new Project());
|
||||
|
||||
$date = new \DateTime();
|
||||
|
||||
$sut->setDurationDay(123456789);
|
||||
$sut->setDurationMonth(23456789);
|
||||
$sut->setDurationTotal(3456789);
|
||||
@@ -44,13 +53,30 @@ class ProjectViewModelTest extends TestCase
|
||||
$sut->setNotExportedDuration(56789);
|
||||
$sut->setNotExportedRate(6789);
|
||||
$sut->setRateTotal(789);
|
||||
$sut->setLastRecord($date);
|
||||
|
||||
self::assertEquals(123456789, $sut->getDurationDay());
|
||||
self::assertEquals(23456789, $sut->getDurationMonth());
|
||||
self::assertEquals(3456789, $sut->getDurationTotal());
|
||||
self::assertEquals(456789, $sut->getDurationWeek());
|
||||
self::assertEquals(56789, $sut->getNotExportedDuration());
|
||||
self::assertEquals(6789, $sut->getNotExportedRate());
|
||||
self::assertEquals(789, $sut->getRateTotal());
|
||||
self::assertSame(123456789, $sut->getDurationDay());
|
||||
self::assertSame(23456789, $sut->getDurationMonth());
|
||||
self::assertSame(3456789, $sut->getDurationTotal());
|
||||
self::assertSame(456789, $sut->getDurationWeek());
|
||||
self::assertSame(56789, $sut->getNotExportedDuration());
|
||||
self::assertSame(6789.0, $sut->getNotExportedRate());
|
||||
self::assertSame(789.0, $sut->getRateTotal());
|
||||
self::assertSame($date, $sut->getLastRecord());
|
||||
|
||||
$date = new \DateTime();
|
||||
$sut->setTimesheetCounter(123);
|
||||
$sut->setLastRecord($date);
|
||||
$sut->setBillableRate(123.456);
|
||||
$sut->setBillableDuration(321);
|
||||
$sut->setNotBilledDuration(9876);
|
||||
$sut->setNotBilledRate(4705.23);
|
||||
|
||||
self::assertSame(123, $sut->getTimesheetCounter());
|
||||
self::assertSame($date, $sut->getLastRecord());
|
||||
self::assertSame(123.456, $sut->getBillableRate());
|
||||
self::assertSame(321, $sut->getBillableDuration());
|
||||
self::assertSame(9876, $sut->getNotBilledDuration());
|
||||
self::assertSame(4705.23, $sut->getNotBilledRate());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,6 @@ class ReportingServiceTest extends TestCase
|
||||
$sut = $this->getSut(true);
|
||||
$reports = $sut->getAvailableReports(new User());
|
||||
self::assertIsArray($reports);
|
||||
self::assertCount(5, $reports);
|
||||
self::assertCount(6, $reports);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Tests\Repository\Query;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Bookmark;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Team;
|
||||
@@ -71,6 +72,7 @@ class BaseQueryTest extends TestCase
|
||||
$this->assertOrderBy($sut, $orderBy);
|
||||
$this->assertOrder($sut, $order);
|
||||
$this->assertTeams($sut);
|
||||
$this->assertBookmark($sut);
|
||||
}
|
||||
|
||||
private function getFormBuilder(string $name)
|
||||
@@ -119,6 +121,16 @@ class BaseQueryTest extends TestCase
|
||||
self::assertSame($team, $sut->getTeams()[0]);
|
||||
}
|
||||
|
||||
protected function assertBookmark(BaseQuery $sut)
|
||||
{
|
||||
$bookmark = new Bookmark();
|
||||
self::assertNull($sut->getBookmark());
|
||||
self::assertFalse($sut->hasBookmark());
|
||||
$sut->setBookmark($bookmark);
|
||||
self::assertSame($bookmark, $sut->getBookmark());
|
||||
self::assertTrue($sut->hasBookmark());
|
||||
}
|
||||
|
||||
protected function assertPage(BaseQuery $sut)
|
||||
{
|
||||
self::assertEquals(1, $sut->getPage());
|
||||
|
||||
@@ -76,4 +76,47 @@ class ThemeEventExtensionTest extends TestCase
|
||||
$values = $sut->getJavascriptTranslations();
|
||||
self::assertCount(24, $values);
|
||||
}
|
||||
|
||||
public function getProgressbarColors()
|
||||
{
|
||||
yield ['progress-bar-danger', 100, false];
|
||||
yield ['progress-bar-danger', 91, false];
|
||||
yield ['progress-bar-warning', 90, false];
|
||||
yield ['progress-bar-warning', 80, false];
|
||||
yield ['progress-bar-warning', 71, false];
|
||||
yield ['progress-bar-success', 70, false];
|
||||
yield ['progress-bar-success', 60, false];
|
||||
yield ['progress-bar-success', 51, false];
|
||||
yield ['progress-bar-primary', 50, false];
|
||||
yield ['progress-bar-primary', 40, false];
|
||||
yield ['progress-bar-primary', 31, false];
|
||||
yield ['progress-bar-info', 30, false];
|
||||
yield ['progress-bar-info', 20, false];
|
||||
yield ['progress-bar-info', 10, false];
|
||||
yield ['progress-bar-info', 0, false];
|
||||
yield ['progress-bar-primary', 100, true];
|
||||
yield ['progress-bar-primary', 91, true];
|
||||
yield ['progress-bar-success', 90, true];
|
||||
yield ['progress-bar-success', 80, true];
|
||||
yield ['progress-bar-success', 71, true];
|
||||
yield ['progress-bar-warning', 70, true];
|
||||
yield ['progress-bar-warning', 60, true];
|
||||
yield ['progress-bar-warning', 51, true];
|
||||
yield ['progress-bar-danger', 50, true];
|
||||
yield ['progress-bar-danger', 40, true];
|
||||
yield ['progress-bar-danger', 31, true];
|
||||
yield ['progress-bar-info', 30, true];
|
||||
yield ['progress-bar-info', 20, true];
|
||||
yield ['progress-bar-info', 10, true];
|
||||
yield ['progress-bar-info', 0, true];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getProgressbarColors
|
||||
*/
|
||||
public function testProgressbarClass(string $expected, int $percent, ?bool $reverseColors = false)
|
||||
{
|
||||
$sut = $this->getSut(false);
|
||||
self::assertEquals($expected, $sut->getProgressbarClass($percent, $reverseColors));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class RuntimeExtensionsTest extends TestCase
|
||||
$expected = [
|
||||
'trigger',
|
||||
'actions',
|
||||
'progressbar_color',
|
||||
'javascript_translations',
|
||||
'active_timesheets',
|
||||
'encore_entry_css_source',
|
||||
|
||||
@@ -109,9 +109,21 @@ class DailyWorkingTimeChartTest extends TestCase
|
||||
$repository = $this->getMockBuilder(TimesheetRepository::class)->disableOriginalConstructor()->onlyMethods(['getDailyData'])->getMock();
|
||||
$repository->expects($this->once())->method('getDailyData')->willReturnCallback(function ($begin, $end, $user) use ($activity, $project) {
|
||||
return [
|
||||
['year' => $begin->format('Y'), 'month' => $begin->format('n'), 'day' => $begin->format('j'), 'rate' => 13.75, 'duration' => 1234, 'details' => [
|
||||
['activity' => $activity, 'project' => $project]
|
||||
]]
|
||||
[
|
||||
'year' => $begin->format('Y'),
|
||||
'month' => $begin->format('n'),
|
||||
'day' => $begin->format('j'),
|
||||
'rate' => 13.75,
|
||||
'duration' => 1234,
|
||||
'billable' => 1234,
|
||||
'details' => [
|
||||
[
|
||||
'activity' => $activity,
|
||||
'project' => $project,
|
||||
'billable' => 1234,
|
||||
]
|
||||
]
|
||||
]
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
<trans-unit id="admin_entity.delete_confirm">
|
||||
<source>admin_entity.delete_confirm</source>
|
||||
<target>
|
||||
Diese Daten werden ebenfalls mit gelöscht!
|
||||
Alternativ können Sie einen Eintrag auswählen, auf den alle existierenden Daten umgebucht werden:
|
||||
</target>
|
||||
Diese Daten werden ebenfalls mit gelöscht!
|
||||
Alternativ können Sie einen Eintrag auswählen, auf den alle existierenden Daten umgebucht werden:
|
||||
</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="delete.not_in_use">
|
||||
<source>delete.not_in_use</source>
|
||||
@@ -1190,6 +1190,14 @@
|
||||
<source>label.not_invoiced</source>
|
||||
<target>Nicht abgerechnet</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.last_record_before">
|
||||
<source>label.last_record_before</source>
|
||||
<target>Keine Zeitbuchung mehr seit</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.last_record">
|
||||
<source>label.last_record</source>
|
||||
<target>Letzter Eintrag</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -1209,6 +1209,14 @@
|
||||
<source>label.not_invoiced</source>
|
||||
<target>Not billed</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.last_record_before">
|
||||
<source>label.last_record_before</source>
|
||||
<target>No time booking since</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.last_record">
|
||||
<source>label.last_record</source>
|
||||
<target>Last entry</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<source>report_project_view</source>
|
||||
<target>Projektübersicht</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="report_inactive_project">
|
||||
<source>report_inactive_project</source>
|
||||
<target>Inaktive Projekte</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="reporting.initial_view">
|
||||
<source>reporting.initial_view</source>
|
||||
<target>Initialer Bericht</target>
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
<source>report_project_view</source>
|
||||
<target>Project overview</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="report_inactive_project">
|
||||
<source>report_inactive_project</source>
|
||||
<target>Inactive projects</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="reporting.initial_view">
|
||||
<source>reporting.initial_view</source>
|
||||
<target>Initial report</target>
|
||||
|
||||
Reference in New Issue
Block a user