diff --git a/.gitignore b/.gitignore index 7bccbd11..be898dff 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ /bin/* !bin/console /config/packages/local.yaml +/config/packages/*/local.yaml templates/invoice/renderer/.~lock* diff --git a/composer.json b/composer.json index eb4bdb19..71d03348 100644 --- a/composer.json +++ b/composer.json @@ -17,8 +17,10 @@ "friendsofsymfony/rest-bundle": "^2.3", "friendsofsymfony/user-bundle": "~2.0", "fzaninotto/faker": "^1.8", + "gedmo/doctrine-extensions": "^2.4", "jms/serializer-bundle": "^2.4", "kevinpapst/adminlte-bundle": "~2.1", + "kimai/kimai2-composer": "^0.1", "mpdf/mpdf": "^7.1", "nelmio/api-doc-bundle": "^3.2", "ocramius/proxy-manager": "2.1.1", diff --git a/composer.lock b/composer.lock index 6cc257f6..cc18a868 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ad6acff0271ed9ad59d9b4ad54ed59cf", + "content-hash": "df4ac341f40ef97bcd3b78174125b1c5", "packages": [ { "name": "beberlei/DoctrineExtensions", @@ -60,6 +60,50 @@ ], "time": "2019-02-04T15:31:57+00:00" }, + { + "name": "behat/transliterator", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/Behat/Transliterator.git", + "reference": "826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Behat/Transliterator/zipball/826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c", + "reference": "826ce7e9c2a6664c0d1f381cbb38b1fb80a7ee2c", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "chuyskywalker/rolling-curl": "^3.1", + "php-yaoi/php-yaoi": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2-dev" + } + }, + "autoload": { + "psr-0": { + "Behat\\Transliterator": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Artistic-1.0" + ], + "description": "String transliterator", + "keywords": [ + "i18n", + "slug", + "transliterator" + ], + "time": "2017-04-04T11:38:05+00:00" + }, { "name": "dama/doctrine-test-bundle", "version": "v5.0.2", @@ -1848,6 +1892,87 @@ ], "time": "2018-07-12T10:23:15+00:00" }, + { + "name": "gedmo/doctrine-extensions", + "version": "v2.4.36", + "source": { + "type": "git", + "url": "https://github.com/Atlantic18/DoctrineExtensions.git", + "reference": "87c78ff9fd4b90460386f753d95622f6fbbfcb27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Atlantic18/DoctrineExtensions/zipball/87c78ff9fd4b90460386f753d95622f6fbbfcb27", + "reference": "87c78ff9fd4b90460386f753d95622f6fbbfcb27", + "shasum": "" + }, + "require": { + "behat/transliterator": "~1.2", + "doctrine/common": "~2.4", + "php": ">=5.3.2" + }, + "conflict": { + "doctrine/annotations": "<1.2" + }, + "require-dev": { + "doctrine/common": ">=2.5.0", + "doctrine/mongodb-odm": ">=1.0.2", + "doctrine/orm": ">=2.5.0", + "phpunit/phpunit": "^4.8.35|^5.7|^6.5", + "symfony/yaml": "~2.6|~3.0|~4.0" + }, + "suggest": { + "doctrine/mongodb-odm": "to use the extensions with the MongoDB ODM", + "doctrine/orm": "to use the extensions with the ORM" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Gedmo\\": "lib/Gedmo" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David Buchmann", + "email": "david@liip.ch" + }, + { + "name": "Gediminas Morkevicius", + "email": "gediminas.morkevicius@gmail.com" + }, + { + "name": "Gustavo Falco", + "email": "comfortablynumb84@gmail.com" + } + ], + "description": "Doctrine2 behavioral extensions", + "homepage": "http://gediminasm.org/", + "keywords": [ + "Blameable", + "behaviors", + "doctrine2", + "extensions", + "gedmo", + "loggable", + "nestedset", + "sluggable", + "sortable", + "timestampable", + "translatable", + "tree", + "uploadable" + ], + "time": "2018-07-26T12:16:35+00:00" + }, { "name": "jdorn/sql-formatter", "version": "v1.2.17", @@ -2148,16 +2273,16 @@ }, { "name": "kevinpapst/adminlte-bundle", - "version": "2.2", + "version": "2.3", "source": { "type": "git", "url": "https://github.com/kevinpapst/AdminLTEBundle.git", - "reference": "f30230e476eec0b22d1f850b6902488d14e0b95e" + "reference": "0d095f9ee7ec1450100f65ea392b938296e15863" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/f30230e476eec0b22d1f850b6902488d14e0b95e", - "reference": "f30230e476eec0b22d1f850b6902488d14e0b95e", + "url": "https://api.github.com/repos/kevinpapst/AdminLTEBundle/zipball/0d095f9ee7ec1450100f65ea392b938296e15863", + "reference": "0d095f9ee7ec1450100f65ea392b938296e15863", "shasum": "" }, "require": { @@ -2205,7 +2330,59 @@ } ], "description": "Admin theme bundle for Symfony 4 based on AdminLTE 2.4.8 with FOSUserBundle support", - "time": "2019-01-20T16:36:22+00:00" + "time": "2019-03-05T20:06:11+00:00" + }, + { + "name": "kimai/kimai2-composer", + "version": "0.1", + "source": { + "type": "git", + "url": "https://github.com/kimai/kimai2-composer.git", + "reference": "7ff68f4270d2e48920309a3c1530f4d247a24905" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kimai/kimai2-composer/zipball/7ff68f4270d2e48920309a3c1530f4d247a24905", + "reference": "7ff68f4270d2e48920309a3c1530f4d247a24905", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0" + }, + "require-dev": { + "composer/composer": "^1.0", + "composer/semver": "^1.0", + "friendsofphp/php-cs-fixer": "^2.10" + }, + "type": "composer-plugin", + "extra": { + "class": "Kimai2\\Composer\\Plugin" + }, + "autoload": { + "psr-4": { + "Kimai2\\Composer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Papst", + "email": "kpapst@gmx.net", + "homepage": "https://github.com/kevinpapst" + } + ], + "description": "An installer for Kimai 2 plugins", + "homepage": "https://github.com/kimai/kimai2-composer", + "keywords": [ + "Kimai", + "installer", + "time-tracker", + "time-tracking" + ], + "time": "2019-03-10T19:34:31+00:00" }, { "name": "markbaker/complex", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index c2cc8916..18c49567 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -31,3 +31,8 @@ doctrine: dir: '%kernel.project_dir%/src/Entity' prefix: 'App\Entity' alias: Kimai + loggable: + type: annotation + alias: Gedmo + prefix: Gedmo\Loggable\Entity + dir: "%kernel.project_dir%/vendor/gedmo/doctrine-extensions/lib/Gedmo/Loggable/Entity" diff --git a/config/routes/test/test_routes.yaml b/config/routes/test/test_routes.yaml index 0f8a2e02..142de5f6 100644 --- a/config/routes/test/test_routes.yaml +++ b/config/routes/test/test_routes.yaml @@ -1,6 +1,6 @@ app.test.activity_recent: path: /{_locale}/activities/recent - controller: App\Controller\ActivityController::recentActivitiesAction + controller: App\Controller\NavbarController::recentActivitiesAction requirements: _locale: '%app_locales%' defaults: diff --git a/config/serializer/App/Entity.Customer.yml b/config/serializer/App/Entity.Customer.yml index e19960a1..27e14c76 100644 --- a/config/serializer/App/Entity.Customer.yml +++ b/config/serializer/App/Entity.Customer.yml @@ -1,6 +1,6 @@ App\Entity\Customer: exclusion_policy: All - custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixedRate, hourlyRate] + custom_accessor_order: [id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixedRate, hourlyRate] properties: id: include: true @@ -41,7 +41,7 @@ App\Entity\Customer: mobile: include: true groups: [Entity] - mail: + email: include: true groups: [Entity] homepage: diff --git a/config/services.yaml b/config/services.yaml index 7aa1a996..1ee904ce 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -51,7 +51,7 @@ services: arguments: $timesheetAsMarkdown: "%kimai.timesheet.markdown%" - App\Controller\Admin\CustomerController: + App\Controller\CustomerController: arguments: $defaults: "%kimai.defaults%" @@ -75,8 +75,7 @@ services: class: App\Doctrine\TimesheetSubscriber arguments: [!tagged timesheet.calculator] tags: - - { name: doctrine.event_listener, event: prePersist, lazy: true } - - { name: doctrine.event_listener, event: preUpdate, lazy: true } + - { name: doctrine.event_subscriber, priority: 50 } # make sure, that sqlite supports foreign keys and cascade deletes App\Doctrine\SqliteSessionInitSubscriber: @@ -177,4 +176,3 @@ services: App\Repository\InvoiceDocumentRepository: class: App\Repository\InvoiceDocumentRepository arguments: ['%kimai.invoice.documents%'] - diff --git a/src/Command/KimaiImporterCommand.php b/src/Command/KimaiImporterCommand.php index 126bfc69..7ab35f52 100644 --- a/src/Command/KimaiImporterCommand.php +++ b/src/Command/KimaiImporterCommand.php @@ -16,6 +16,7 @@ use App\Entity\Project; use App\Entity\Timesheet; use App\Entity\User; use App\Entity\UserPreference; +use App\Timesheet\Util; use Doctrine\Common\Persistence\ObjectManager; use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Connection; @@ -177,18 +178,9 @@ class KimaiImporterCommand extends Command return; } - // pre-load all data to make sure we can fully import everything - $users = null; - $customer = null; - $projects = null; - $activities = null; - $records = null; - $activityToProject = null; - $fixedRates = null; - $rates = null; - $bytesStart = memory_get_usage(true); + // pre-load all data to make sure we can fully import everything try { $users = $this->fetchAllFromImport('users'); } catch (\Exception $ex) { @@ -965,7 +957,7 @@ class KimaiImporterCommand extends Command continue; } - $duration = $oldRecord['end'] - $oldRecord['start']; + $duration = (int) ($oldRecord['end'] - $oldRecord['start']); // ----------------------- unknown user, damned missing data integrity in Kimai v1 ----------------------- if (!isset($this->users[$oldRecord['userID']])) { @@ -1021,9 +1013,9 @@ class KimaiImporterCommand extends Command if ($timesheet->getFixedRate() !== null) { $timesheet->setRate($timesheet->getFixedRate()); } elseif ($timesheet->getHourlyRate() !== null) { - $hourlyRate = $timesheet->getHourlyRate(); - $rate = (float) $hourlyRate * ($duration / 3600); - $timesheet->setRate(round($rate, 2)); + $hourlyRate = (float) $timesheet->getHourlyRate(); + $rate = Util::calculateRate($hourlyRate, $duration); + $timesheet->setRate($rate); } $user = $this->users[$oldRecord['userID']]; diff --git a/src/Controller/Admin/AboutController.php b/src/Controller/AboutController.php similarity index 93% rename from src/Controller/Admin/AboutController.php rename to src/Controller/AboutController.php index 83ab9d3e..612cc27f 100644 --- a/src/Controller/Admin/AboutController.php +++ b/src/Controller/AboutController.php @@ -7,12 +7,10 @@ * file that was distributed with this source code. */ -namespace App\Controller\Admin; +namespace App\Controller; use App\Constants; -use App\Controller\AbstractController; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; /** @@ -38,11 +36,10 @@ class AboutController extends AbstractController /** * @Route(path="", name="about", methods={"GET"}) - - * @param Request $request + * * @return \Symfony\Component\HttpFoundation\Response */ - public function indexAction(Request $request) + public function indexAction() { $phpInfo = $this->getPhpInfo(); unset($phpInfo[0]); @@ -74,7 +71,7 @@ class AboutController extends AbstractController } } - return $this->render('admin/system.html.twig', [ + return $this->render('about/system.html.twig', [ 'modules' => get_loaded_extensions(), 'dotenv' => [ 'APP_ENV' => getenv('APP_ENV'), diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 74b3b109..1b23286a 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -10,19 +10,28 @@ namespace App\Controller; use App\Entity\Activity; +use App\Form\ActivityEditForm; +use App\Form\Toolbar\ActivityToolbarForm; +use App\Form\Type\ActivityType; use App\Repository\ActivityRepository; +use App\Repository\Query\ActivityQuery; +use Doctrine\ORM\ORMException; +use Pagerfanta\Pagerfanta; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; -use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Annotation\Route; /** - * Controller used to manage activity contents in the public part of the site. + * Controller used to manage activities in the admin part of the site. * - * @Security("is_granted('ROLE_USER')") + * @Route(path="/admin/activity") + * @Security("is_granted('view_activity')") */ class ActivityController extends AbstractController { /** - * @return ActivityRepository + * @return \App\Repository\ActivityRepository */ protected function getRepository() { @@ -30,18 +39,190 @@ class ActivityController extends AbstractController } /** - * The flyout to render recent activities and quick-start new recordings. + * @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"}) + * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"}) + * @Cache(smaxage="10") + * @Security("is_granted('view_activity')") * - * @return Response + * @param int $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response */ - public function recentActivitiesAction() + public function indexAction($page, Request $request) { - $user = $this->getUser(); - $entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-1 year')); + $query = new ActivityQuery(); + $query + ->setOrderBy('name') + ->setExclusiveVisibility(true) + ->setPage($page) + ; + + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var ActivityQuery $query */ + $query = $form->getData(); + } + + /* @var $entries Pagerfanta */ + $entries = $this->getRepository()->findByQuery($query); + + return $this->render('activity/index.html.twig', [ + 'entries' => $entries, + 'query' => $query, + 'showFilter' => $form->isSubmitted(), + 'toolbarForm' => $form->createView(), + ]); + } + + /** + * @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"}) + * @Security("is_granted('create_activity')") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function createAction(Request $request) + { + return $this->renderActivityForm(new Activity(), $request); + } + + /** + * @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"}) + * @Security("is_granted('edit', activity)") + * + * @param Activity $activity + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function editAction(Activity $activity, Request $request) + { + return $this->renderActivityForm($activity, $request); + } + + /** + * @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"}) + * @Security("is_granted('delete', activity)") + * + * @param Activity $activity + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function deleteAction(Activity $activity, Request $request) + { + $stats = $this->getRepository()->getActivityStatistics($activity); + + $deleteForm = $this->createFormBuilder() + ->add('activity', ActivityType::class, [ + 'label' => 'label.activity', + 'query_builder' => function (ActivityRepository $repo) use ($activity) { + $query = new ActivityQuery(); + $query + ->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER) + ->setProject($activity->getProject()) + ->setOrderGlobalsFirst(true) + ->addIgnoredEntity($activity) + ->setGlobalsOnly(null === $activity->getProject()) + ; + + return $repo->findByQuery($query); + }, + 'required' => false, + ]) + ->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()])) + ->setMethod('POST') + ->getForm(); + + $deleteForm->handleRequest($request); + + if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) { + try { + $this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData()); + $this->flashSuccess('action.delete.success'); + } catch (ORMException $ex) { + $this->flashError('action.delete.error'); + } + + return $this->redirectToRoute('admin_activity'); + } return $this->render( - 'navbar/recent-activities.html.twig', - ['entries' => $entries] + 'activity/delete.html.twig', + [ + 'activity' => $activity, + 'stats' => $stats, + 'form' => $deleteForm->createView(), + ] ); } + + /** + * @param Activity $activity + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + protected function renderActivityForm(Activity $activity, Request $request) + { + $editForm = $this->createEditForm($activity); + + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($activity); + $entityManager->flush(); + + $this->flashSuccess('action.update.success'); + + if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { + $newActivity = new Activity(); + $newActivity->setProject($activity->getProject()); + $editForm = $this->createEditForm($newActivity); + $editForm->get('create_more')->setData(true); + $activity = $newActivity; + } else { + return $this->redirectToRoute('admin_activity'); + } + } + + return $this->render( + 'activity/edit.html.twig', + [ + 'activity' => $activity, + 'form' => $editForm->createView() + ] + ); + } + + /** + * @param ActivityQuery $query + * @return \Symfony\Component\Form\FormInterface + */ + protected function getToolbarForm(ActivityQuery $query) + { + return $this->createForm(ActivityToolbarForm::class, $query, [ + 'action' => $this->generateUrl('admin_activity', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); + } + + /** + * @param Activity $activity + * @return \Symfony\Component\Form\FormInterface + */ + private function createEditForm(Activity $activity) + { + if ($activity->getId() === null) { + $url = $this->generateUrl('admin_activity_create'); + } else { + $url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]); + } + + return $this->createForm(ActivityEditForm::class, $activity, [ + 'action' => $url, + 'method' => 'POST' + ]); + } } diff --git a/src/Controller/Admin/ActivityController.php b/src/Controller/Admin/ActivityController.php deleted file mode 100644 index d87073d0..00000000 --- a/src/Controller/Admin/ActivityController.php +++ /dev/null @@ -1,229 +0,0 @@ -getDoctrine()->getRepository(Activity::class); - } - - /** - * @Route(path="/", defaults={"page": 1}, name="admin_activity", methods={"GET"}) - * @Route(path="/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated", methods={"GET"}) - * @Cache(smaxage="10") - * @Security("is_granted('view_activity')") - * - * @param int $page - * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response - */ - public function indexAction($page, Request $request) - { - $query = new ActivityQuery(); - $query - ->setOrderBy('name') - ->setExclusiveVisibility(true) - ->setPage($page) - ; - - $form = $this->getToolbarForm($query); - $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - /** @var ActivityQuery $query */ - $query = $form->getData(); - } - - /* @var $entries Pagerfanta */ - $entries = $this->getRepository()->findByQuery($query); - - return $this->render('admin/activity.html.twig', [ - 'entries' => $entries, - 'query' => $query, - 'showFilter' => $form->isSubmitted(), - 'toolbarForm' => $form->createView(), - ]); - } - - /** - * @Route(path="/create", name="admin_activity_create", methods={"GET", "POST"}) - * @Security("is_granted('create_activity')") - * - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - public function createAction(Request $request) - { - return $this->renderActivityForm(new Activity(), $request); - } - - /** - * @Route(path="/{id}/edit", name="admin_activity_edit", methods={"GET", "POST"}) - * @Security("is_granted('edit', activity)") - * - * @param Activity $activity - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - public function editAction(Activity $activity, Request $request) - { - return $this->renderActivityForm($activity, $request); - } - - /** - * @Route(path="/{id}/delete", name="admin_activity_delete", methods={"GET", "POST"}) - * @Security("is_granted('delete', activity)") - * - * @param Activity $activity - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - public function deleteAction(Activity $activity, Request $request) - { - $stats = $this->getRepository()->getActivityStatistics($activity); - - $deleteForm = $this->createFormBuilder() - ->add('activity', ActivityType::class, [ - 'label' => 'label.activity', - 'query_builder' => function (ActivityRepository $repo) use ($activity) { - $query = new ActivityQuery(); - $query - ->setResultType(ActivityQuery::RESULT_TYPE_QUERYBUILDER) - ->setProject($activity->getProject()) - ->setOrderGlobalsFirst(true) - ->addIgnoredEntity($activity) - ->setGlobalsOnly(null === $activity->getProject()) - ; - - return $repo->findByQuery($query); - }, - 'required' => false, - ]) - ->setAction($this->generateUrl('admin_activity_delete', ['id' => $activity->getId()])) - ->setMethod('POST') - ->getForm(); - - $deleteForm->handleRequest($request); - - if (0 == $stats->getRecordAmount() || ($deleteForm->isSubmitted() && $deleteForm->isValid())) { - try { - $this->getRepository()->deleteActivity($activity, $deleteForm->get('activity')->getData()); - $this->flashSuccess('action.delete.success'); - } catch (ORMException $ex) { - $this->flashError('action.delete.error'); - } - - return $this->redirectToRoute('admin_activity'); - } - - return $this->render( - 'admin/activity_delete.html.twig', - [ - 'activity' => $activity, - 'stats' => $stats, - 'form' => $deleteForm->createView(), - ] - ); - } - - /** - * @param Activity $activity - * @param Request $request - * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response - */ - protected function renderActivityForm(Activity $activity, Request $request) - { - $editForm = $this->createEditForm($activity); - - $editForm->handleRequest($request); - - if ($editForm->isSubmitted() && $editForm->isValid()) { - $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($activity); - $entityManager->flush(); - - $this->flashSuccess('action.update.success'); - - if ($editForm->has('create_more') && $editForm->get('create_more')->getData() === true) { - $newActivity = new Activity(); - $newActivity->setProject($activity->getProject()); - $editForm = $this->createEditForm($newActivity); - $editForm->get('create_more')->setData(true); - $activity = $newActivity; - } else { - return $this->redirectToRoute('admin_activity'); - } - } - - return $this->render( - 'admin/activity_edit.html.twig', - [ - 'activity' => $activity, - 'form' => $editForm->createView() - ] - ); - } - - /** - * @param ActivityQuery $query - * @return \Symfony\Component\Form\FormInterface - */ - protected function getToolbarForm(ActivityQuery $query) - { - return $this->createForm(ActivityToolbarForm::class, $query, [ - 'action' => $this->generateUrl('admin_activity', [ - 'page' => $query->getPage(), - ]), - 'method' => 'GET', - ]); - } - - /** - * @param Activity $activity - * @return \Symfony\Component\Form\FormInterface - */ - private function createEditForm(Activity $activity) - { - if ($activity->getId() === null) { - $url = $this->generateUrl('admin_activity_create'); - } else { - $url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]); - } - - return $this->createForm(ActivityEditForm::class, $activity, [ - 'action' => $url, - 'method' => 'POST' - ]); - } -} diff --git a/src/Controller/Admin/CustomerController.php b/src/Controller/CustomerController.php similarity index 95% rename from src/Controller/Admin/CustomerController.php rename to src/Controller/CustomerController.php index 33a213d4..f8f139d5 100644 --- a/src/Controller/Admin/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -7,9 +7,8 @@ * file that was distributed with this source code. */ -namespace App\Controller\Admin; +namespace App\Controller; -use App\Controller\AbstractController; use App\Entity\Customer; use App\Form\CustomerEditForm; use App\Form\Toolbar\CustomerToolbarForm; @@ -23,7 +22,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Annotation\Route; /** - * Controller used to manage activities in the admin part of the site. + * Controller used to manage customer in the admin part of the site. * * @Route(path="/admin/customer") * @Security("is_granted('view_customer')") @@ -78,7 +77,7 @@ class CustomerController extends AbstractController /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); - return $this->render('admin/customer.html.twig', [ + return $this->render('customer/index.html.twig', [ 'entries' => $entries, 'query' => $query, 'showFilter' => $form->isSubmitted(), @@ -158,7 +157,7 @@ class CustomerController extends AbstractController return $this->redirectToRoute('admin_customer'); } - return $this->render('admin/customer_delete.html.twig', [ + return $this->render('customer/delete.html.twig', [ 'customer' => $customer, 'stats' => $stats, 'form' => $deleteForm->createView(), @@ -186,7 +185,7 @@ class CustomerController extends AbstractController return $this->redirectToRoute('admin_customer'); } - return $this->render('admin/customer_edit.html.twig', [ + return $this->render('customer/edit.html.twig', [ 'customer' => $customer, 'form' => $editForm->createView() ]); diff --git a/src/Controller/NavbarController.php b/src/Controller/NavbarController.php new file mode 100644 index 00000000..567e7773 --- /dev/null +++ b/src/Controller/NavbarController.php @@ -0,0 +1,60 @@ +repository = $repository; + } + + /** + * @return ActivityRepository + */ + protected function getRepository() + { + return $this->repository; + } + + /** + * The flyout to render recent activities and quick-start new recordings. + * + * @return Response + * @throws \Doctrine\ORM\Query\QueryException + */ + public function recentActivitiesAction() + { + $user = $this->getUser(); + $entries = $this->getRepository()->getRecentActivities($user, new \DateTime('-1 year')); + + return $this->render( + 'navbar/recent-activities.html.twig', + ['entries' => $entries] + ); + } +} diff --git a/src/Controller/Admin/ProjectController.php b/src/Controller/ProjectController.php similarity index 96% rename from src/Controller/Admin/ProjectController.php rename to src/Controller/ProjectController.php index 6675bc7f..073cbddd 100644 --- a/src/Controller/Admin/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -7,9 +7,8 @@ * file that was distributed with this source code. */ -namespace App\Controller\Admin; +namespace App\Controller; -use App\Controller\AbstractController; use App\Entity\Customer; use App\Entity\Project; use App\Form\ProjectEditForm; @@ -69,7 +68,7 @@ class ProjectController extends AbstractController /* @var $entries Pagerfanta */ $entries = $this->getDoctrine()->getRepository(Project::class)->findByQuery($query); - return $this->render('admin/project.html.twig', [ + return $this->render('project/index.html.twig', [ 'entries' => $entries, 'query' => $query, 'showFilter' => $form->isSubmitted(), @@ -145,7 +144,7 @@ class ProjectController extends AbstractController return $this->redirectToRoute('admin_project'); } - return $this->render('admin/project_delete.html.twig', [ + return $this->render('project/delete.html.twig', [ 'project' => $project, 'stats' => $stats, 'form' => $deleteForm->createView(), @@ -181,7 +180,7 @@ class ProjectController extends AbstractController } } - return $this->render('admin/project_edit.html.twig', [ + return $this->render('project/edit.html.twig', [ 'project' => $project, 'form' => $editForm->createView() ]); diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index 124be452..117cfa28 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -238,7 +238,7 @@ class TimesheetController extends AbstractController $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); } - return $this->redirectToRoute('timesheet_paginated', ['page' => $request->get('page')]); + return $this->redirectToRoute('timesheet_paginated', ['page' => $request->get('page', 1)]); } /** diff --git a/src/Controller/TimesheetControllerTrait.php b/src/Controller/TimesheetControllerTrait.php index 6921c487..4874cd92 100644 --- a/src/Controller/TimesheetControllerTrait.php +++ b/src/Controller/TimesheetControllerTrait.php @@ -103,11 +103,11 @@ trait TimesheetControllerTrait $this->flashSuccess('action.update.success'); - return $this->redirectToRoute($redirectRoute, ['page' => $request->get('page')]); + return $this->redirectToRoute($redirectRoute, ['page' => $request->get('page', 1)]); } return $this->render($renderTemplate, [ - 'entry' => $entry, + 'timesheet' => $entry, 'form' => $editForm->createView(), ]); } @@ -180,7 +180,7 @@ trait TimesheetControllerTrait } return $this->render($renderTemplate, [ - 'entry' => $entry, + 'timesheet' => $entry, 'form' => $createForm->createView(), ]); } diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/TimesheetTeamController.php similarity index 93% rename from src/Controller/Admin/TimesheetController.php rename to src/Controller/TimesheetTeamController.php index a0184935..68dc2e03 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/TimesheetTeamController.php @@ -7,10 +7,8 @@ * file that was distributed with this source code. */ -namespace App\Controller\Admin; +namespace App\Controller; -use App\Controller\AbstractController; -use App\Controller\TimesheetControllerTrait; use App\Entity\Timesheet; use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; @@ -26,7 +24,7 @@ use Symfony\Component\Routing\Annotation\Route; * @Route(path="/team/timesheet") * @Security("is_granted('view_other_timesheet')") */ -class TimesheetController extends AbstractController +class TimesheetTeamController extends AbstractController { use TimesheetControllerTrait; @@ -60,7 +58,7 @@ class TimesheetController extends AbstractController /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); - return $this->render('admin/timesheet.html.twig', [ + return $this->render('timesheet-team/index.html.twig', [ 'entries' => $entries, 'page' => $query->getPage(), 'query' => $query, @@ -101,7 +99,7 @@ class TimesheetController extends AbstractController /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); - return $this->render('admin/timesheet_export.html.twig', [ + return $this->render('timesheet-team/export.html.twig', [ 'entries' => $entries, 'query' => $query, ]); @@ -129,7 +127,7 @@ class TimesheetController extends AbstractController */ public function editAction(Timesheet $entry, Request $request) { - return $this->edit($entry, $request, 'admin_timesheet_paginated', 'admin/timesheet_edit.html.twig'); + return $this->edit($entry, $request, 'admin_timesheet_paginated', 'timesheet-team/edit.html.twig'); } /** @@ -141,7 +139,7 @@ class TimesheetController extends AbstractController */ public function createAction(Request $request) { - return $this->create($request, 'admin_timesheet', 'admin/timesheet_edit.html.twig'); + return $this->create($request, 'admin_timesheet', 'timesheet-team/edit.html.twig'); } /** @@ -164,7 +162,7 @@ class TimesheetController extends AbstractController $this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]); } - return $this->redirectToRoute('admin_timesheet_paginated', ['page' => $request->get('page')]); + return $this->redirectToRoute('admin_timesheet_paginated', ['page' => $request->get('page', 1)]); } /** diff --git a/src/Controller/Admin/UserController.php b/src/Controller/UserController.php similarity index 96% rename from src/Controller/Admin/UserController.php rename to src/Controller/UserController.php index 517cbaf1..b4a39063 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/UserController.php @@ -7,9 +7,8 @@ * file that was distributed with this source code. */ -namespace App\Controller\Admin; +namespace App\Controller; -use App\Controller\AbstractController; use App\Entity\Timesheet; use App\Entity\User; use App\Form\Toolbar\UserToolbarForm; @@ -74,7 +73,7 @@ class UserController extends AbstractController /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); - return $this->render('admin/user.html.twig', [ + return $this->render('user/index.html.twig', [ 'entries' => $entries, 'query' => $query, 'showFilter' => $form->isSubmitted(), @@ -119,7 +118,7 @@ class UserController extends AbstractController } return $this->render( - 'admin/user_edit.html.twig', + 'user/edit.html.twig', [ 'user' => $user, 'form' => $editForm->createView() @@ -159,7 +158,7 @@ class UserController extends AbstractController } return $this->render( - 'admin/user_delete.html.twig', + 'user/delete.html.twig', [ 'user' => $userToDelete, 'stats' => $stats, diff --git a/src/DataFixtures/CustomerFixtures.php b/src/DataFixtures/CustomerFixtures.php index 56da29da..faa126cd 100644 --- a/src/DataFixtures/CustomerFixtures.php +++ b/src/DataFixtures/CustomerFixtures.php @@ -23,6 +23,8 @@ use Faker\Generator; * * Execute this command to load the data: * bin/console doctrine:fixtures:load + * + * @codeCoverageIgnore */ class CustomerFixtures extends Fixture { @@ -32,7 +34,7 @@ class CustomerFixtures extends Fixture public const MAX_BUDGET = 100000; public const MIN_GLOBAL_ACTIVITIES = 5; public const MAX_GLOBAL_ACTIVITIES = 50; - public const MIN_PROJECTS_PER_CUSTOMER = 1; + public const MIN_PROJECTS_PER_CUSTOMER = 2; public const MAX_PROJECTS_PER_CUSTOMER = 25; public const MIN_ACTIVITIES_PER_PROJECT = 0; public const MAX_ACTIVITIES_PER_PROJECT = 25; @@ -48,6 +50,7 @@ class CustomerFixtures extends Fixture for ($c = 1; $c <= $amountCustomers; $c++) { $visibleCustomer = 0 != $c % 5; $customer = $this->createCustomer($faker, $visibleCustomer); + $manager->persist($customer); $projectForCustomer = rand(self::MIN_PROJECTS_PER_CUSTOMER, self::MAX_PROJECTS_PER_CUSTOMER); for ($p = 1; $p <= $projectForCustomer; $p++) { @@ -63,8 +66,6 @@ class CustomerFixtures extends Fixture } } - $manager->persist($customer); - $manager->flush(); $manager->clear(); } diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php index 335e065b..f6bbebf7 100644 --- a/src/DataFixtures/InvoiceFixtures.php +++ b/src/DataFixtures/InvoiceFixtures.php @@ -21,6 +21,8 @@ use Faker\Generator; * * Execute this command to load the data: * $ php bin/console doctrine:fixtures:load + * + * @codeCoverageIgnore */ class InvoiceFixtures extends Fixture { diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php index 089ccde6..dfbdf08d 100644 --- a/src/DataFixtures/TimesheetFixtures.php +++ b/src/DataFixtures/TimesheetFixtures.php @@ -14,6 +14,7 @@ use App\Entity\Project; use App\Entity\Timesheet; use App\Entity\User; use App\Entity\UserPreference; +use App\Timesheet\Util; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\Persistence\ObjectManager; @@ -25,6 +26,8 @@ use Faker\Factory; * * Execute this command to load the data: * bin/console doctrine:fixtures:load + * + * @codeCoverageIgnore */ class TimesheetFixtures extends Fixture implements DependentFixtureInterface { @@ -181,11 +184,12 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface $end = $end->modify('+ ' . (rand(self::MIN_MINUTES_PER_ENTRY, self::MAX_MINUTES_PER_ENTRY)) . ' minutes'); $duration = $end->getTimestamp() - $start->getTimestamp(); - $rate = $user->getPreferenceValue(UserPreference::HOURLY_RATE); + $hourlyRate = (float) $user->getPreferenceValue(UserPreference::HOURLY_RATE); + $rate = Util::calculateRate($hourlyRate, $duration); $entry ->setEnd($end) - ->setRate(round(($duration / 3600) * $rate)) + ->setRate($rate) ->setDuration($duration); } diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index ab9e9be9..cdbbffc3 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -22,6 +22,8 @@ use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; * * Execute this command to load the data: * $ php bin/console doctrine:fixtures:load + * + * @codeCoverageIgnore */ class UserFixtures extends Fixture { diff --git a/src/Doctrine/AbstractMigration.php b/src/Doctrine/AbstractMigration.php index 1be48a11..db36aa71 100644 --- a/src/Doctrine/AbstractMigration.php +++ b/src/Doctrine/AbstractMigration.php @@ -11,6 +11,7 @@ namespace App\Doctrine; use Doctrine\Common\Persistence\Mapping\ClassMetadata; use Doctrine\DBAL\DBALException; +use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration as BaseAbstractMigration; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -50,6 +51,55 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai return getenv('DATABASE_PREFIX') . $name; } + /** + * @param Schema $schema + * @throws DBALException + */ + public function preUp(Schema $schema): void + { + $this->abortIfPlatformNotSupported(); + } + + /** + * @param Schema $schema + * @throws DBALException + */ + public function preDown(Schema $schema): void + { + $this->abortIfPlatformNotSupported(); + } + + /** + * Abort the migration is the current platform is not supported. + * + * @throws DBALException + */ + protected function abortIfPlatformNotSupported() + { + $platform = $this->getPlatform(); + if (!in_array($platform, ['sqlite', 'mysql'])) { + $this->abortIf(true, 'Unsupported database platform: ' . $platform); + } + } + + /** + * @return bool + * @throws DBALException + */ + protected function isPlatformSqlite() + { + return ($this->getPlatform() === 'sqlite'); + } + + /** + * @return bool + * @throws DBALException + */ + protected function isPlatformMysql() + { + return ($this->getPlatform() === 'mysql'); + } + /** * @return string * @throws DBALException @@ -84,7 +134,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai protected function addSqlDropIndex($indexName, $tableName) { $dropSql = 'DROP INDEX ' . $indexName; - if ($this->getPlatform() === 'mysql') { + if (!$this->isPlatformSqlite()) { $dropSql .= ' ON ' . $tableName; } $this->addSql($dropSql); diff --git a/src/Doctrine/SqliteSessionInitSubscriber.php b/src/Doctrine/SqliteSessionInitSubscriber.php index 861c4962..9c3f04a0 100644 --- a/src/Doctrine/SqliteSessionInitSubscriber.php +++ b/src/Doctrine/SqliteSessionInitSubscriber.php @@ -15,6 +15,16 @@ use Doctrine\DBAL\Events; class SqliteSessionInitSubscriber implements EventSubscriber { + /** + * {@inheritdoc} + */ + public function getSubscribedEvents() + { + return [ + Events::postConnect, + ]; + } + /** * @param ConnectionEventArgs $args * @throws \Doctrine\DBAL\DBALException @@ -24,14 +34,7 @@ class SqliteSessionInitSubscriber implements EventSubscriber if ('sqlite' !== strtolower($args->getDatabasePlatform()->getName())) { return; } + $args->getConnection()->executeUpdate('PRAGMA foreign_keys = ON;'); } - - /** - * {@inheritdoc} - */ - public function getSubscribedEvents() - { - return [Events::postConnect]; - } } diff --git a/src/Doctrine/TimesheetSubscriber.php b/src/Doctrine/TimesheetSubscriber.php index 5d94a58d..97f2c845 100644 --- a/src/Doctrine/TimesheetSubscriber.php +++ b/src/Doctrine/TimesheetSubscriber.php @@ -12,8 +12,8 @@ namespace App\Doctrine; use App\Entity\Timesheet; use App\Timesheet\CalculatorInterface; use Doctrine\Common\EventSubscriber; -use Doctrine\ORM\Event\LifecycleEventArgs; -use Doctrine\ORM\Event\PreUpdateEventArgs; +use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\ORM\Events; /** * A listener to make sure all Timesheet entries will have a proper duration. @@ -26,7 +26,6 @@ class TimesheetSubscriber implements EventSubscriber protected $calculator; /** - * TimesheetSubscriber constructor. * @param iterable $calculators */ public function __construct(iterable $calculators) @@ -48,38 +47,43 @@ class TimesheetSubscriber implements EventSubscriber public function getSubscribedEvents() { return [ - 'prePersist', - 'preUpdate', + Events::onFlush, ]; } /** - * @param PreUpdateEventArgs $args + * @param OnFlushEventArgs $args */ - public function preUpdate(PreUpdateEventArgs $args) + public function onFlush(OnFlushEventArgs $args) { - $this->calculateFields($args); - } + $em = $args->getEntityManager(); + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(Timesheet::class); - /** - * @param LifecycleEventArgs $args - */ - public function prePersist(LifecycleEventArgs $args) - { - $this->calculateFields($args); - } + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if (!($entity instanceof Timesheet)) { + continue; + } - /** - * @param LifecycleEventArgs $args - */ - protected function calculateFields(LifecycleEventArgs $args) - { - $entity = $args->getObject(); - - if (!($entity instanceof Timesheet)) { - return; + $this->calculateFields($entity); + $uow->recomputeSingleEntityChangeSet($meta, $entity); } + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if (!($entity instanceof Timesheet)) { + continue; + } + + $this->calculateFields($entity); + $uow->recomputeSingleEntityChangeSet($meta, $entity); + } + } + + /** + * @param Timesheet $entity + */ + protected function calculateFields(Timesheet $entity) + { foreach ($this->calculator as $calculator) { $calculator->calculate($entity); } diff --git a/src/Entity/Activity.php b/src/Entity/Activity.php index 21721456..7999dc7c 100644 --- a/src/Entity/Activity.php +++ b/src/Entity/Activity.php @@ -13,8 +13,6 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * Activity - * * @ORM\Table(name="activities") * @ORM\Entity(repositoryClass="App\Repository\ActivityRepository") */ @@ -71,7 +69,7 @@ class Activity /** * @var float * - * @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="fixed_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $fixedRate = null; @@ -79,7 +77,7 @@ class Activity /** * @var float * - * @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="hourly_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $hourlyRate = null; diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 5ef52785..ddd9e985 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -13,8 +13,6 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * Customer - * * @ORM\Table(name="customers") * @ORM\Entity(repositoryClass="App\Repository\CustomerRepository") */ @@ -130,9 +128,9 @@ class Customer /** * @var string * - * @ORM\Column(name="mail", type="string", length=255, nullable=true) + * @ORM\Column(name="email", type="string", length=255, nullable=true) */ - private $mail; + private $email; /** * @var string @@ -152,7 +150,7 @@ class Customer /** * @var float * - * @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="fixed_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $fixedRate = null; @@ -160,7 +158,7 @@ class Customer /** * @var float * - * @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="hourly_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $hourlyRate = null; @@ -443,9 +441,9 @@ class Customer * @param string $mail * @return Customer */ - public function setMail($mail) + public function setEmail($mail) { - $this->mail = $mail; + $this->email = $mail; return $this; } @@ -455,9 +453,9 @@ class Customer * * @return string */ - public function getMail() + public function getEmail() { - return $this->mail; + return $this->email; } /** diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 8a7c930c..f1ca59cc 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -13,8 +13,6 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * Project - * * @ORM\Table(name="projects") * @ORM\Entity(repositoryClass="App\Repository\ProjectRepository") */ @@ -73,7 +71,7 @@ class Project /** * @var float * - * @ORM\Column(name="budget", type="decimal", precision=10, scale=2, nullable=false) + * @ORM\Column(name="budget", type="float", precision=10, scale=2, nullable=false) * @Assert\NotNull() */ private $budget = 0.00; @@ -88,7 +86,7 @@ class Project /** * @var float * - * @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="fixed_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $fixedRate = null; @@ -96,7 +94,7 @@ class Project /** * @var float * - * @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="hourly_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $hourlyRate = null; diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index 5e4c50cd..e0b9a0f7 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -13,8 +13,6 @@ use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; /** - * Timesheet entity. - * * @ORM\Table( * name="timesheet", * indexes={ @@ -109,7 +107,7 @@ class Timesheet /** * @var float * - * @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false) + * @ORM\Column(name="rate", type="float", precision=10, scale=2, nullable=false) * @Assert\GreaterThanOrEqual(0) */ private $rate = 0.00; @@ -117,7 +115,7 @@ class Timesheet /** * @var float * - * @ORM\Column(name="fixed_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="fixed_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $fixedRate = null; @@ -125,7 +123,7 @@ class Timesheet /** * @var float * - * @ORM\Column(name="hourly_rate", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="hourly_rate", type="float", precision=10, scale=2, nullable=true) * @Assert\GreaterThanOrEqual(0) */ private $hourlyRate = null; @@ -211,7 +209,7 @@ class Timesheet if (null === $end) { $this->duration = 0; - $this->rate = 0; + $this->rate = 0.00; } else { $this->timezone = $end->getTimezone()->getName(); } @@ -298,8 +296,6 @@ class Timesheet } /** - * Set description - * * @param string $description * @return Timesheet */ @@ -311,8 +307,6 @@ class Timesheet } /** - * Get description - * * @return string */ public function getDescription() @@ -321,8 +315,6 @@ class Timesheet } /** - * Set rate - * * @param float $rate * @return Timesheet */ @@ -334,8 +326,6 @@ class Timesheet } /** - * Get rate - * * @return float */ public function getRate() diff --git a/src/Event/ThemeEvent.php b/src/Event/ThemeEvent.php index 6ca3faa6..9ddeb4de 100644 --- a/src/Event/ThemeEvent.php +++ b/src/Event/ThemeEvent.php @@ -11,7 +11,6 @@ namespace App\Event; use App\Entity\User; use Symfony\Component\EventDispatcher\Event; -use Symfony\Component\HttpFoundation\Request; class ThemeEvent extends Event { @@ -23,23 +22,22 @@ class ThemeEvent extends Event * @var User */ protected $user; - /** - * @var Request - */ - protected $request; /** * @var string */ protected $content = ''; + /** + * @var mixed + */ + protected $payload = null; /** - * @param Request $request - * @param User $user + * @param string $name */ - public function __construct(Request $request, User $user) + public function __construct(User $user, $payload = null) { - $this->request = $request; $this->user = $user; + $this->payload = $payload; } /** @@ -50,14 +48,6 @@ class ThemeEvent extends Event return $this->user; } - /** - * @return Request - */ - public function getRequest(): Request - { - return $this->request; - } - /** * @return string */ @@ -76,4 +66,22 @@ class ThemeEvent extends Event return $this; } + + /** + * @return mixed + */ + public function getPayload() + { + return $this->payload; + } + + /** + * @param mixed $payload + * @return ThemeEvent + */ + public function setPayload($payload) + { + $this->payload = $payload; + return $this; + } } diff --git a/src/Export/Renderer/AbstractSpreadsheetRenderer.php b/src/Export/Renderer/AbstractSpreadsheetRenderer.php index 134d116c..e8a99493 100644 --- a/src/Export/Renderer/AbstractSpreadsheetRenderer.php +++ b/src/Export/Renderer/AbstractSpreadsheetRenderer.php @@ -110,8 +110,8 @@ abstract class AbstractSpreadsheetRenderer $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourly_rate')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixed_rate')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration')); $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate')); diff --git a/src/Form/ActivityEditForm.php b/src/Form/ActivityEditForm.php index 8192ab0b..b62284c9 100644 --- a/src/Form/ActivityEditForm.php +++ b/src/Form/ActivityEditForm.php @@ -96,12 +96,12 @@ class ActivityEditForm extends AbstractType $builder ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixed_rate', + 'label' => 'label.fixedRate', 'required' => false, 'currency' => $currency, ]) ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourly_rate', + 'label' => 'label.hourlyRate', 'required' => false, 'currency' => $currency, ]) diff --git a/src/Form/CustomerEditForm.php b/src/Form/CustomerEditForm.php index 4fc99e5e..cd28f67e 100644 --- a/src/Form/CustomerEditForm.php +++ b/src/Form/CustomerEditForm.php @@ -42,7 +42,7 @@ class CustomerEditForm extends AbstractType 'label' => 'label.name', ]) ->add('number', TextType::class, [ - 'label' => 'label.customer_number', + 'label' => 'label.number', 'required' => false, ]) ->add('comment', TextareaType::class, [ @@ -81,7 +81,7 @@ class CustomerEditForm extends AbstractType 'required' => false, 'attr' => ['icon' => 'mobile'], ]) - ->add('mail', EmailType::class, [ + ->add('email', EmailType::class, [ 'label' => 'label.email', 'required' => false, ]) @@ -93,12 +93,12 @@ class CustomerEditForm extends AbstractType 'label' => 'label.timezone', ]) ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixed_rate', + 'label' => 'label.fixedRate', 'required' => false, 'currency' => $customer->getCurrency() ?? false, ]) ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourly_rate', + 'label' => 'label.hourlyRate', 'required' => false, 'currency' => $customer->getCurrency() ?? false, ]) diff --git a/src/Form/ProjectEditForm.php b/src/Form/ProjectEditForm.php index fed1fcc6..d15c0282 100644 --- a/src/Form/ProjectEditForm.php +++ b/src/Form/ProjectEditForm.php @@ -52,7 +52,7 @@ class ProjectEditForm extends AbstractType 'required' => false, ]) ->add('orderNumber', TextType::class, [ - 'label' => 'label.order_number', + 'label' => 'label.orderNumber', 'required' => false, ]) ->add('customer', CustomerType::class, [ @@ -62,12 +62,12 @@ class ProjectEditForm extends AbstractType }, ]) ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixed_rate', + 'label' => 'label.fixedRate', 'required' => false, 'currency' => $currency, ]) ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourly_rate', + 'label' => 'label.hourlyRate', 'required' => false, 'currency' => $currency, ]) diff --git a/src/Form/TimesheetEditForm.php b/src/Form/TimesheetEditForm.php index dc5b8256..29f47dcb 100644 --- a/src/Form/TimesheetEditForm.php +++ b/src/Form/TimesheetEditForm.php @@ -265,12 +265,12 @@ class TimesheetEditForm extends AbstractType if ($options['include_rate']) { $builder ->add('fixedRate', MoneyType::class, [ - 'label' => 'label.fixed_rate', + 'label' => 'label.fixedRate', 'required' => false, 'currency' => $currency, ]) ->add('hourlyRate', MoneyType::class, [ - 'label' => 'label.hourly_rate', + 'label' => 'label.hourlyRate', 'required' => false, 'currency' => $currency, ]); diff --git a/src/Migrations/Version20190124004014.php b/src/Migrations/Version20190124004014.php index acf90152..0cbae3b9 100644 --- a/src/Migrations/Version20190124004014.php +++ b/src/Migrations/Version20190124004014.php @@ -16,6 +16,8 @@ use Doctrine\DBAL\Schema\Schema; /** * Adds the exported column to the timesheet table + * + * @version 0.8 */ final class Version20190124004014 extends AbstractMigration { diff --git a/src/Migrations/Version20190201150324.php b/src/Migrations/Version20190201150324.php index 662e72b4..5ab586bb 100644 --- a/src/Migrations/Version20190201150324.php +++ b/src/Migrations/Version20190201150324.php @@ -17,6 +17,8 @@ use Doctrine\DBAL\Schema\Schema; /** * Adds the timezone column to the timesheet table * See https://github.com/kevinpapst/kimai2/pull/372 for further information. + * + * @version 0.8 */ final class Version20190201150324 extends AbstractMigration { diff --git a/src/Migrations/Version20190219200020.php b/src/Migrations/Version20190219200020.php index 4279a54e..5a1d0e55 100644 --- a/src/Migrations/Version20190219200020.php +++ b/src/Migrations/Version20190219200020.php @@ -16,6 +16,8 @@ use Doctrine\DBAL\Schema\Schema; /** * Cleanup the user_preferences table from old configs. + * + * @version 0.9 */ final class Version20190219200020 extends AbstractMigration { diff --git a/src/Migrations/Version20190305152308.php b/src/Migrations/Version20190305152308.php new file mode 100644 index 00000000..3086ae3d --- /dev/null +++ b/src/Migrations/Version20190305152308.php @@ -0,0 +1,141 @@ +getTableName('customers'); + $projects = $this->getTableName('projects'); + $activities = $this->getTableName('activities'); + $timesheet = $this->getTableName('timesheet'); + $users = $this->getTableName('users'); + + if ($this->isPlatformSqlite()) { + // first backup of ALL tables + $this->addSql('DROP INDEX IDX_4F60C6B181C06096'); + $this->addSql('DROP INDEX IDX_4F60C6B18D93D649'); + $this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_timesheet AS SELECT id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported FROM ' . $timesheet); + + $this->addSql('DROP INDEX IDX_8811FE1C166D1F9C'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_activities AS SELECT id, project_id, name, comment, visible, fixed_rate, hourly_rate FROM ' . $activities); + + $this->addSql('DROP INDEX IDX_407F12069395C3F3'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_projects AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects); + + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_customers AS SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixed_rate, hourly_rate FROM ' . $customers); + + // now we can drop and re-create the tables + $this->addSql('DROP TABLE ' . $customers); + $this->addSql('CREATE TABLE ' . $customers . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL COLLATE BINARY, number VARCHAR(50) DEFAULT NULL COLLATE BINARY, comment CLOB DEFAULT NULL COLLATE BINARY, visible BOOLEAN NOT NULL, company VARCHAR(255) DEFAULT NULL COLLATE BINARY, contact VARCHAR(255) DEFAULT NULL COLLATE BINARY, address CLOB DEFAULT NULL COLLATE BINARY, country VARCHAR(2) NOT NULL COLLATE BINARY, currency VARCHAR(3) NOT NULL COLLATE BINARY, phone VARCHAR(255) DEFAULT NULL COLLATE BINARY, fax VARCHAR(255) DEFAULT NULL COLLATE BINARY, mobile VARCHAR(255) DEFAULT NULL COLLATE BINARY, email VARCHAR(255) DEFAULT NULL COLLATE BINARY, homepage VARCHAR(255) DEFAULT NULL COLLATE BINARY, timezone VARCHAR(255) NOT NULL COLLATE BINARY, fixed_rate DOUBLE PRECISION DEFAULT NULL, hourly_rate DOUBLE PRECISION DEFAULT NULL)'); + $this->addSql('INSERT INTO ' . $customers . ' (id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixed_rate, hourly_rate) SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixed_rate, hourly_rate FROM __temp__kimai2_customers'); + $this->addSql('DROP TABLE __temp__kimai2_customers'); + + $this->addSql('DROP TABLE ' . $projects); + $this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL COLLATE BINARY, order_number CLOB DEFAULT NULL COLLATE BINARY, comment CLOB DEFAULT NULL COLLATE BINARY, visible BOOLEAN NOT NULL, budget DOUBLE PRECISION NOT NULL, fixed_rate DOUBLE PRECISION DEFAULT NULL, hourly_rate DOUBLE PRECISION DEFAULT NULL, CONSTRAINT FK_407F12069395C3F3 FOREIGN KEY (customer_id) REFERENCES ' . $customers . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__kimai2_projects'); + $this->addSql('DROP TABLE __temp__kimai2_projects'); + $this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)'); + + $this->addSql('DROP TABLE ' . $activities); + $this->addSql('CREATE TABLE ' . $activities . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, project_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL COLLATE BINARY, comment CLOB DEFAULT NULL COLLATE BINARY, visible BOOLEAN NOT NULL, fixed_rate DOUBLE PRECISION DEFAULT NULL, hourly_rate DOUBLE PRECISION DEFAULT NULL, CONSTRAINT FK_8811FE1C166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO ' . $activities . ' (id, project_id, name, comment, visible, fixed_rate, hourly_rate) SELECT id, project_id, name, comment, visible, fixed_rate, hourly_rate FROM __temp__kimai2_activities'); + $this->addSql('DROP TABLE __temp__kimai2_activities'); + $this->addSql('CREATE INDEX IDX_8811FE1C166D1F9C ON ' . $activities . ' (project_id)'); + + $this->addSql('DROP TABLE ' . $timesheet); + $this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL --(DC2Type:datetime) + , timezone VARCHAR(64) NOT NULL COLLATE BINARY, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL COLLATE BINARY, exported BOOLEAN NOT NULL, end_time DATETIME DEFAULT NULL --(DC2Type:datetime) + , rate DOUBLE PRECISION NOT NULL, fixed_rate DOUBLE PRECISION DEFAULT NULL, hourly_rate DOUBLE PRECISION DEFAULT NULL, CONSTRAINT FK_4F60C6B18D93D649 FOREIGN KEY (user) REFERENCES ' . $users . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B181C06096 FOREIGN KEY (activity_id) REFERENCES ' . $activities . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_4F60C6B1166D1F9C FOREIGN KEY (project_id) REFERENCES ' . $projects . ' (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)'); + $this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported) SELECT id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported FROM __temp__kimai2_timesheet'); + $this->addSql('DROP TABLE __temp__kimai2_timesheet'); + $this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)'); + $this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)'); + $this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)'); + } else { + $this->addSql('ALTER TABLE ' . $activities . ' CHANGE fixed_rate fixed_rate DOUBLE PRECISION DEFAULT NULL, CHANGE hourly_rate hourly_rate DOUBLE PRECISION DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $customers . ' CHANGE mail email VARCHAR(255) DEFAULT NULL, CHANGE fixed_rate fixed_rate DOUBLE PRECISION DEFAULT NULL, CHANGE hourly_rate hourly_rate DOUBLE PRECISION DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $projects . ' CHANGE budget budget DOUBLE PRECISION NOT NULL, CHANGE fixed_rate fixed_rate DOUBLE PRECISION DEFAULT NULL, CHANGE hourly_rate hourly_rate DOUBLE PRECISION DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $timesheet . ' CHANGE rate rate DOUBLE PRECISION NOT NULL, CHANGE fixed_rate fixed_rate DOUBLE PRECISION DEFAULT NULL, CHANGE hourly_rate hourly_rate DOUBLE PRECISION DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + $customers = $this->getTableName('customers'); + $projects = $this->getTableName('projects'); + $activities = $this->getTableName('activities'); + $timesheet = $this->getTableName('timesheet'); + + if ($this->isPlatformSqlite()) { + // first backup of ALL tables + $this->addSql('DROP INDEX IDX_8811FE1C166D1F9C'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_activities AS SELECT id, project_id, name, comment, visible, fixed_rate, hourly_rate FROM ' . $activities); + + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_customers AS SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixed_rate, hourly_rate FROM ' . $customers); + + $this->addSql('DROP INDEX IDX_407F12069395C3F3'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_projects AS SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM ' . $projects); + + $this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C'); + $this->addSql('DROP INDEX IDX_4F60C6B18D93D649'); + $this->addSql('DROP INDEX IDX_4F60C6B181C06096'); + $this->addSql('CREATE TEMPORARY TABLE __temp__kimai2_timesheet AS SELECT id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported FROM ' . $timesheet); + + // now we can drop and re-create the tables + $this->addSql('DROP TABLE ' . $activities); + $this->addSql('CREATE TABLE ' . $activities . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, project_id INTEGER DEFAULT NULL, name VARCHAR(255) NOT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)'); + $this->addSql('INSERT INTO ' . $activities . ' (id, project_id, name, comment, visible, fixed_rate, hourly_rate) SELECT id, project_id, name, comment, visible, fixed_rate, hourly_rate FROM __temp__kimai2_activities'); + $this->addSql('DROP TABLE __temp__kimai2_activities'); + $this->addSql('CREATE INDEX IDX_8811FE1C166D1F9C ON ' . $activities . ' (project_id)'); + + $this->addSql('DROP TABLE ' . $customers); + $this->addSql('CREATE TABLE ' . $customers . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(255) NOT NULL, number VARCHAR(50) DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, company VARCHAR(255) DEFAULT NULL, contact VARCHAR(255) DEFAULT NULL, address CLOB DEFAULT NULL, country VARCHAR(2) NOT NULL, currency VARCHAR(3) NOT NULL, phone VARCHAR(255) DEFAULT NULL, fax VARCHAR(255) DEFAULT NULL, mobile VARCHAR(255) DEFAULT NULL, mail VARCHAR(255) DEFAULT NULL, homepage VARCHAR(255) DEFAULT NULL, timezone VARCHAR(255) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)'); + $this->addSql('INSERT INTO ' . $customers . ' (id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, mail, homepage, timezone, fixed_rate, hourly_rate) SELECT id, name, number, comment, visible, company, contact, address, country, currency, phone, fax, mobile, email, homepage, timezone, fixed_rate, hourly_rate FROM __temp__kimai2_customers'); + $this->addSql('DROP TABLE __temp__kimai2_customers'); + + $this->addSql('DROP TABLE ' . $projects); + $this->addSql('CREATE TABLE ' . $projects . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, customer_id INTEGER NOT NULL, name VARCHAR(255) NOT NULL, order_number CLOB DEFAULT NULL, comment CLOB DEFAULT NULL, visible BOOLEAN NOT NULL, budget NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)'); + $this->addSql('INSERT INTO ' . $projects . ' (id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate) SELECT id, customer_id, name, order_number, comment, visible, budget, fixed_rate, hourly_rate FROM __temp__kimai2_projects'); + $this->addSql('DROP TABLE __temp__kimai2_projects'); + $this->addSql('CREATE INDEX IDX_407F12069395C3F3 ON ' . $projects . ' (customer_id)'); + + $this->addSql('DROP TABLE ' . $timesheet); + $this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL --(DC2Type:datetime) + , timezone VARCHAR(64) NOT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, exported BOOLEAN NOT NULL, end_time DATETIME DEFAULT NULL --(DC2Type:datetime) + , rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)'); + $this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported) SELECT id, user, activity_id, project_id, start_time, end_time, timezone, duration, description, rate, fixed_rate, hourly_rate, exported FROM __temp__kimai2_timesheet'); + $this->addSql('DROP TABLE __temp__kimai2_timesheet'); + $this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)'); + $this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)'); + $this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)'); + } else { + $this->addSql('ALTER TABLE ' . $activities . ' CHANGE fixed_rate fixed_rate NUMERIC(10, 2) DEFAULT NULL, CHANGE hourly_rate hourly_rate NUMERIC(10, 2) DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $customers . ' CHANGE email mail VARCHAR(255) DEFAULT NULL, CHANGE fixed_rate fixed_rate NUMERIC(10, 2) DEFAULT NULL, CHANGE hourly_rate hourly_rate NUMERIC(10, 2) DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $projects . ' CHANGE budget budget NUMERIC(10, 2) NOT NULL, CHANGE fixed_rate fixed_rate NUMERIC(10, 2) DEFAULT NULL, CHANGE hourly_rate hourly_rate NUMERIC(10, 2) DEFAULT NULL'); + $this->addSql('ALTER TABLE ' . $timesheet . ' CHANGE rate rate NUMERIC(10, 2) NOT NULL, CHANGE fixed_rate fixed_rate NUMERIC(10, 2) DEFAULT NULL, CHANGE hourly_rate hourly_rate NUMERIC(10, 2) DEFAULT NULL'); + } + } +} diff --git a/src/Repository/Query/ActivityQuery.php b/src/Repository/Query/ActivityQuery.php index 59f7d23b..9d50beb9 100644 --- a/src/Repository/Query/ActivityQuery.php +++ b/src/Repository/Query/ActivityQuery.php @@ -53,14 +53,14 @@ class ActivityQuery extends ProjectQuery */ public function isGlobalsOnly(): bool { - return $this->globalsOnly; + return (bool) $this->globalsOnly; } /** * @param bool $globalsOnly * @return ActivityQuery */ - public function setGlobalsOnly(bool $globalsOnly) + public function setGlobalsOnly($globalsOnly) { $this->globalsOnly = $globalsOnly; diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index e9193a05..235463bd 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -82,10 +82,10 @@ class TimesheetRepository extends AbstractRepository switch ($type) { case self::STATS_QUERY_ACTIVE: return count($this->getActiveEntries($user)); - break; + case self::STATS_QUERY_MONTHLY: return $this->getMonthlyStats($user, $begin, $end); - break; + case self::STATS_QUERY_DURATION: $what = 'SUM(t.duration)'; break; diff --git a/src/Timesheet/Calculator/RateCalculator.php b/src/Timesheet/Calculator/RateCalculator.php index 3b8dc4cc..9e968128 100644 --- a/src/Timesheet/Calculator/RateCalculator.php +++ b/src/Timesheet/Calculator/RateCalculator.php @@ -12,6 +12,7 @@ namespace App\Timesheet\Calculator; use App\Entity\Timesheet; use App\Entity\UserPreference; use App\Timesheet\CalculatorInterface; +use App\Timesheet\Util; /** * Implementation to calculate the rate for a timesheet record. @@ -54,8 +55,8 @@ class RateCalculator implements CalculatorInterface $hourlyRate = $this->findHourlyRate($record); $factor = $this->getRateFactor($record); - $hourlyRate = (float) $hourlyRate * $factor; - $rate = (float) $hourlyRate * ($record->getDuration() / 3600); + $hourlyRate = (float) ($hourlyRate * $factor); + $rate = Util::calculateRate($hourlyRate, $record->getDuration()); $record->setHourlyRate($hourlyRate); $record->setRate($rate); diff --git a/src/Timesheet/Util.php b/src/Timesheet/Util.php new file mode 100644 index 00000000..90371266 --- /dev/null +++ b/src/Timesheet/Util.php @@ -0,0 +1,31 @@ +dateTimeFormat); } + /** + * @param DateTime $date + * @return string + */ + public function dateTimeFull(DateTime $date) + { + if (null === $this->dateTimeTypeFormat) { + $this->dateTimeTypeFormat = $this->localeSettings->getDateTimeTypeFormat(); + } + + $formatter = new \IntlDateFormatter( + $this->localeSettings->getLocale(), + \IntlDateFormatter::MEDIUM, + \IntlDateFormatter::MEDIUM, + date_default_timezone_get(), + \IntlDateFormatter::GREGORIAN, + $this->dateTimeTypeFormat + ); + + return $formatter->format($date); + } + /** * @param DateTime $date * @param string $format diff --git a/src/Twig/EntityExtensions.php b/src/Twig/EntityExtensions.php new file mode 100644 index 00000000..90aad583 --- /dev/null +++ b/src/Twig/EntityExtensions.php @@ -0,0 +1,163 @@ +users = $users; + $this->customers = $customers; + $this->projects = $projects; + $this->activities = $activities; + } + + /** + * {@inheritdoc} + */ + public function getFilters() + { + return [ + new TwigFilter('user', [$this, 'getUser']), + new TwigFilter('customer', [$this, 'getCustomer']), + new TwigFilter('project', [$this, 'getProject']), + new TwigFilter('activity', [$this, 'getActivity']), + ]; + } + + /** + * @param int|User $user + * @param bool $allowEmpty + * @return User|null + */ + public function getUser($user, $allowEmpty = true) + { + if ($user instanceof User) { + return $user; + } + + $entity = $this->users->getById($user); + + if (null === $entity) { + $entity = $this->users->loadUserByUsername($user); + } + + if (null === $entity && false === $allowEmpty) { + $entity = new User(); + $entity->setUsername(self::UNKNOWN_NAME); + } + + return $entity; + } + + /** + * @param int|Customer $customer + * @param bool $allowEmpty + * @return Customer|null + */ + public function getCustomer($customer, $allowEmpty = true) + { + if ($customer instanceof Customer) { + return $customer; + } + + $entity = $this->customers->getById($customer); + + if (null === $entity && false === $allowEmpty) { + $entity = new Customer(); + $entity->setName(self::UNKNOWN_NAME); + } + + return $entity; + } + + /** + * @param int|Project $project + * @param bool $allowEmpty + * @return Project|null + */ + public function getProject($project, $allowEmpty = true) + { + if ($project instanceof Project) { + return $project; + } + + $entity = $this->projects->getById($project); + + if (null === $entity && false === $allowEmpty) { + $entity = new Project(); + $entity->setName(self::UNKNOWN_NAME); + $entity->setCustomer((new Customer())->setName(self::UNKNOWN_NAME)); + } + + return $entity; + } + + /** + * @param int|Activity $activity + * @param bool $allowEmpty + * @return Activity|null + */ + public function getActivity($activity, $allowEmpty = true) + { + if ($activity instanceof Activity) { + return $activity; + } + + $entity = $this->activities->getById($activity); + + if (null === $entity && false === $allowEmpty) { + $entity = new Activity(); + $entity->setName(self::UNKNOWN_NAME); + } + + return $entity; + } +} diff --git a/src/Controller/EventController.php b/src/Twig/EventExtensions.php similarity index 55% rename from src/Controller/EventController.php rename to src/Twig/EventExtensions.php index 5a0b871d..389fbcc8 100644 --- a/src/Controller/EventController.php +++ b/src/Twig/EventExtensions.php @@ -7,27 +7,43 @@ * file that was distributed with this source code. */ -namespace App\Controller; +namespace App\Twig; +use App\Entity\User; use App\Event\ThemeEvent; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; +use App\Security\CurrentUser; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; -class EventController extends Controller +class EventExtensions extends AbstractExtension { /** * @var EventDispatcherInterface */ protected $eventDispatcher; + /** + * @var User + */ + protected $user; /** * @param EventDispatcherInterface $dispatcher */ - public function __construct(EventDispatcherInterface $dispatcher) + public function __construct(EventDispatcherInterface $dispatcher, CurrentUser $user) { $this->eventDispatcher = $dispatcher; + $this->user = $user->getUser(); + } + + /** + * {@inheritdoc} + */ + public function getFunctions() + { + return [ + new TwigFunction('trigger', [$this, 'triggerEvent']), + ]; } /** @@ -49,19 +65,19 @@ class EventController extends Controller } /** - * @param Request $request * @param string $eventName - * @return ThemeEvent|Response + * @param mixed $payload + * @return ThemeEvent */ - public function trigger(Request $request, string $event) + public function triggerEvent(string $eventName, $payload = null) { - if (!$this->hasListener($event)) { - return new Response(); + $themeEvent = new ThemeEvent($this->user, $payload); + + if ($this->hasListener($eventName)) { + $this->getDispatcher()->dispatch($eventName, $themeEvent); } - $themeEvent = new ThemeEvent($request, $this->getUser()); - $this->getDispatcher()->dispatch($event, $themeEvent); - - return new Response($themeEvent->getContent()); + return $themeEvent; } + } diff --git a/src/Twig/Extensions.php b/src/Twig/Extensions.php index de65268b..92683b8d 100644 --- a/src/Twig/Extensions.php +++ b/src/Twig/Extensions.php @@ -16,12 +16,14 @@ use App\Utils\LocaleSettings; use NumberFormatter; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Intl\Intl; +use Twig\Extension\AbstractExtension; use Twig\TwigFilter; +use Twig\TwigFunction; /** * Multiple Twig extensions: filters and functions */ -class Extensions extends \Twig_Extension +class Extensions extends AbstractExtension { /** * @var LocaleSettings @@ -98,6 +100,7 @@ class Extensions extends \Twig_Extension 'xlsx' => 'fas fa-file-excel', 'on' => 'fas fa-toggle-on', 'off' => 'fas fa-toggle-off', + 'audit' => 'fas fa-history', ]; /** @@ -132,12 +135,26 @@ class Extensions extends \Twig_Extension public function getFunctions() { return [ - new \Twig_SimpleFunction('locales', [$this, 'getLocales']), - new \Twig_SimpleFunction('is_visible_column', [$this, 'isColumnVisible']), - new \Twig_SimpleFunction('is_datatable_configured', [$this, 'isDatatableConfigured']), + new TwigFunction('locales', [$this, 'getLocales']), + new TwigFunction('is_visible_column', [$this, 'isColumnVisible']), + new TwigFunction('is_datatable_configured', [$this, 'isDatatableConfigured']), + new TwigFunction('class_name', [$this, 'getClassName']), ]; } + /** + * @param $object + * @return null|string + */ + public function getClassName($object) + { + if (!is_object($object)) { + return null; + } + + return get_class($object); + } + /** * @param string $dataTable * @param string $size diff --git a/src/Twig/MarkdownExtension.php b/src/Twig/MarkdownExtension.php index 2ecc89b9..790e236c 100644 --- a/src/Twig/MarkdownExtension.php +++ b/src/Twig/MarkdownExtension.php @@ -10,12 +10,13 @@ namespace App\Twig; use App\Utils\Markdown; +use Twig\Extension\AbstractExtension; use Twig\TwigFilter; /** * A twig extension to handle markdown parser. */ -class MarkdownExtension extends \Twig_Extension +class MarkdownExtension extends AbstractExtension { /** * @var Markdown diff --git a/src/Utils/LocaleSettings.php b/src/Utils/LocaleSettings.php index a0845950..aebfa3a4 100644 --- a/src/Utils/LocaleSettings.php +++ b/src/Utils/LocaleSettings.php @@ -152,7 +152,7 @@ class LocaleSettings */ public function isTwentyFourHours(?string $locale = null): bool { - return $this->getConfigByLocaleAndKey('24_hours', $locale); + return (bool) $this->getConfigByLocaleAndKey('24_hours', $locale); } /** diff --git a/src/Validator/Constraints/TimesheetValidator.php b/src/Validator/Constraints/TimesheetValidator.php index ebe3e999..ad9cbc04 100644 --- a/src/Validator/Constraints/TimesheetValidator.php +++ b/src/Validator/Constraints/TimesheetValidator.php @@ -9,7 +9,7 @@ namespace App\Validator\Constraints; -use App\Entity\Timesheet; +use App\Entity\Timesheet as TimesheetEntity; use App\Validator\Constraints\Timesheet as TimesheetConstraint; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; use Symfony\Component\Validator\Constraint; @@ -59,7 +59,7 @@ class TimesheetValidator extends ConstraintValidator } /** - * @param Timesheet $value + * @param TimesheetEntity $value * @param Constraint $constraint */ public function validate($value, Constraint $constraint) @@ -68,7 +68,7 @@ class TimesheetValidator extends ConstraintValidator throw new UnexpectedTypeException($constraint, __NAMESPACE__ . '\Timesheet'); } - if (!is_object($value) || !($value instanceof Timesheet)) { + if (!is_object($value) || !($value instanceof TimesheetEntity)) { return; } @@ -78,10 +78,10 @@ class TimesheetValidator extends ConstraintValidator } /** - * @param Timesheet $timesheet + * @param TimesheetEntity $timesheet * @param ExecutionContextInterface $context */ - protected function validatePermissions(Timesheet $timesheet, ExecutionContextInterface $context) + protected function validatePermissions(TimesheetEntity $timesheet, ExecutionContextInterface $context) { // special case that would otherwise need to be validated in several controllers: // an entry is edited and the end date is removed (or duration deleted) would restart the record, @@ -102,10 +102,10 @@ class TimesheetValidator extends ConstraintValidator } /** - * @param Timesheet $timesheet + * @param TimesheetEntity $timesheet * @param ExecutionContextInterface $context */ - protected function validateBeginAndEnd(Timesheet $timesheet, ExecutionContextInterface $context) + protected function validateBeginAndEnd(TimesheetEntity $timesheet, ExecutionContextInterface $context) { if (null === $timesheet->getBegin()) { $context->buildViolation('You must submit a begin date.') @@ -135,10 +135,10 @@ class TimesheetValidator extends ConstraintValidator } /** - * @param Timesheet $timesheet + * @param TimesheetEntity $timesheet * @param ExecutionContextInterface $context */ - protected function validateActivityAndProject(Timesheet $timesheet, ExecutionContextInterface $context) + protected function validateActivityAndProject(TimesheetEntity $timesheet, ExecutionContextInterface $context) { if (null === ($activity = $timesheet->getActivity())) { $context->buildViolation('A timesheet must have an activity.') diff --git a/symfony.lock b/symfony.lock index 6804df39..11c38aee 100644 --- a/symfony.lock +++ b/symfony.lock @@ -2,6 +2,9 @@ "beberlei/doctrineextensions": { "version": "v1.2.0" }, + "behat/transliterator": { + "version": "v1.2.0" + }, "composer/semver": { "version": "1.4.2" }, @@ -137,6 +140,9 @@ "fzaninotto/faker": { "version": "v1.8.0" }, + "gedmo/doctrine-extensions": { + "version": "v2.4.36" + }, "jdorn/sql-formatter": { "version": "v1.2.17" }, @@ -161,6 +167,9 @@ "kevinpapst/adminlte-bundle": { "version": "0.3" }, + "kimai/kimai2-composer": { + "version": "0.1" + }, "markbaker/complex": { "version": "1.4.6" }, diff --git a/templates/admin/system.html.twig b/templates/about/system.html.twig similarity index 100% rename from templates/admin/system.html.twig rename to templates/about/system.html.twig diff --git a/templates/admin/activity_delete.html.twig b/templates/activity/delete.html.twig similarity index 89% rename from templates/admin/activity_delete.html.twig rename to templates/activity/delete.html.twig index aa84d088..4bfbf8a2 100644 --- a/templates/admin/activity_delete.html.twig +++ b/templates/activity/delete.html.twig @@ -1,9 +1,11 @@ {% extends 'base.html.twig' %} {% import "macros/widgets.html.twig" as widgets %} {% import "macros/datatables.html.twig" as tables %} +{% import "macros/actions.html.twig" as actions %} {% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }}{% endblock %} +{% block page_actions %}{{ actions.activity(activity, 'delete') }}{% endblock %} {% block main %} diff --git a/templates/admin/activity_edit.html.twig b/templates/activity/edit.html.twig similarity index 73% rename from templates/admin/activity_edit.html.twig rename to templates/activity/edit.html.twig index 3ffa595f..ddb9498b 100644 --- a/templates/admin/activity_edit.html.twig +++ b/templates/activity/edit.html.twig @@ -1,7 +1,10 @@ {% extends app.request.xmlHttpRequest ? 'form.html.twig' : 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/actions.html.twig" as actions %} {% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }}{% endblock %} +{% block page_actions %}{{ actions.activity(activity, 'edit') }}{% endblock %} {% block main %} {{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { diff --git a/templates/admin/activity.html.twig b/templates/activity/index.html.twig similarity index 74% rename from templates/admin/activity.html.twig rename to templates/activity/index.html.twig index 3509a4f5..797df8ab 100644 --- a/templates/admin/activity.html.twig +++ b/templates/activity/index.html.twig @@ -2,16 +2,11 @@ {% import "macros/widgets.html.twig" as widgets %} {% import "macros/datatables.html.twig" as tables %} {% import "macros/toolbar.html.twig" as toolbar %} +{% import "macros/actions.html.twig" as actions %} {% block page_title %}{{ 'admin_activity.title'|trans }}{% endblock %} {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} -{% block page_actions %} - {% set actions = {'filter': '#collapseActivityAdmin', 'visibility': '#modal_activity_admin'} %} - {% if is_granted('create_activity') %} - {% set actions = actions|merge({'create': path('admin_activity_create')}) %} - {% endif %} - {{ widgets.page_actions(actions) }} -{% endblock %} +{% block page_actions %}{{ actions.activities('index') }}{% endblock %} {% block main_before %} {{ toolbar.toolbar(toolbarForm, 'collapseActivityAdmin', showFilter) }} @@ -55,14 +50,7 @@
{{ 'invoice.due_days'|trans }}: {{ model.dueDate|date_short }}
{% if model.customer.number is not empty %}
-
{{ 'label.customer_number'|trans }}: {{ model.customer.number }}
+
{{ 'label.number'|trans }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
-
{{ 'label.order_number'|trans }}: {{ model.query.project.orderNumber }}
+
{{ 'label.orderNumber'|trans }}: {{ model.query.project.orderNumber }}
{% endif %}