diff --git a/app/Resources/translations/AvanzuAdminTheme.de.xliff b/app/Resources/translations/AvanzuAdminTheme.de.xliff new file mode 100644 index 00000000..7043d766 --- /dev/null +++ b/app/Resources/translations/AvanzuAdminTheme.de.xliff @@ -0,0 +1,13 @@ + + + + + + + Toggle navigation + Ansicht der Seitenleiste umschalten + + + + + diff --git a/app/Resources/translations/exceptions.de.xliff b/app/Resources/translations/exceptions.de.xliff new file mode 100644 index 00000000..3ebc85a6 --- /dev/null +++ b/app/Resources/translations/exceptions.de.xliff @@ -0,0 +1,17 @@ + + + + + + + access.denied + Der Zugriff wurde verweigert + + + timesheet.deny.stop + Der Benutzer "%user%" darf den Zeiteintrag "%entry%" nicht aufrufen + + + + + diff --git a/app/Resources/translations/flashmessages.de.xliff b/app/Resources/translations/flashmessages.de.xliff new file mode 100644 index 00000000..8edee45a --- /dev/null +++ b/app/Resources/translations/flashmessages.de.xliff @@ -0,0 +1,29 @@ + + + + + + + timesheet.stop.success + Zeitmessung wurde gestoppt + + + timesheet.stop.error + Zeitmessung konnte nicht gestoppt werden: %reason% + + + timesheet.start.success + Zeitmessung wurde gestartet + + + timesheet.start.error + Zeitmessung konnte nicht gestartet werden: %reason% + + + action.updated_successfully + Änderungen erfolgreich gespeichert + + + + + diff --git a/app/Resources/translations/messages.de.xliff b/app/Resources/translations/messages.de.xliff index de19d52c..c5e91af6 100644 --- a/app/Resources/translations/messages.de.xliff +++ b/app/Resources/translations/messages.de.xliff @@ -212,7 +212,7 @@ label.rate - Umsatz + Lohn label.language @@ -250,10 +250,6 @@ action.back Zurück - - action.updated_successfully - Änderungen erfolgreich gespeichert - - - tracking.active - Sie haben %count% aktive Einträge + + active.entries + Sie haben %count% aktiven Eintrag|Sie haben %count% aktive Einträge - - tracking.all + + timesheet.all Alle Einträge anzeigen + + recent.activities + Ihr %count% zuletzt bearbeitete Aktivität|Eine ihrer %count% letzten Aktivitäten neustarten + + + recent.activities.format + %activity% in %project% für %customer% + + + timesheet.start + Neue Zeitmessung starten + diff --git a/app/Resources/views/base.html.twig b/app/Resources/views/base.html.twig index ec5a2538..49be2e55 100644 --- a/app/Resources/views/base.html.twig +++ b/app/Resources/views/base.html.twig @@ -1,6 +1,7 @@ {% extends 'AvanzuAdminThemeBundle:layout:default-layout.html.twig' %} {% block avanzu_page_content %} + {{ include('default/_flash_messages.html.twig') }} {% block main %}{% endblock %} {% endblock %} @@ -8,10 +9,6 @@ {% block title %}{{ 'browser.title'|trans }}{% endblock %} {% endblock %} -{% block avanzu_sidebar_search %} - -{% endblock %} - {% block avanzu_page_title %} {% block page_title %}{% endblock %} {% endblock %} @@ -32,10 +29,6 @@ Kimai - Time Tracking {% endblock %} -{% block flash_messages %} - {{ include('default/_flash_messages.html.twig') }} -{% endblock %} - {% block avanzu_head %} @@ -47,17 +40,11 @@ $(document).ready(function() { $('.dropdown-toggle').dropdown(); $(document).kimai({imagePath: '{{ asset('images') }}'}); - $(document).kimai('ticktac', 'a#ticktac'); + //$(document).kimai('pauseRecord', 'li.messages-menu ul.menu li'); }); {% endblock %} -{% block avanzu_sidebar_user %} - {% if app.user is not null and is_granted('IS_AUTHENTICATED_FULLY') %} - {{ render(controller('TimesheetBundle:Timesheet:statusEntry')) }} - {% endif %} -{% endblock %} - {% block avanzu_footer %} {% block footer %}
@@ -67,32 +54,42 @@ Copyright © {{ 'now'|date('Y') }} - Kevin Papst. {{ 'footer.license'|trans }}
+ {% endblock %} {% endblock %} -{# -avanzu_navbar_toggle -avanzu_navbar_messages -avanzu_navbar_notifications -avanzu_navbar_tasks -avanzu_navbar_user -avanzu_navbar_control_sidebar_toggle -avanzu_sidebar_user -avanzu_sidebar_nav -avanzu_breadcrumb -avanzu_control_sidebar -avanzu_javascripts -avanzu_javascripts_inline +{% block avanzu_navbar_messages %} + {% if app.user is not null and is_granted('IS_AUTHENTICATED_FULLY') %} + {{ render(controller('TimesheetBundle:Timesheet:activeEntries')) }} + {% endif %} +{% endblock %} - +{% block avanzu_navbar_notifications %} + {% if app.user is not null and is_granted('IS_AUTHENTICATED_FULLY') %} + {{ render(controller('TimesheetBundle:Activity:recentActivities')) }} + {% endif %} + {# + + #} +{% endblock %} - +{% block avanzu_navbar_toggle %}{{ parent() }}{% endblock %} +{% block avanzu_navbar_tasks %}{{ parent() }}{% endblock %} +{% block avanzu_navbar_user %}{{ parent() }}{% endblock %} +{% block avanzu_navbar_control_sidebar_toggle %}{{ parent() }}{% endblock %} -#} \ No newline at end of file +{% block avanzu_sidebar_user %}{# right now we simply deactivate that box #}{% endblock %} +{% block avanzu_sidebar_search %}{# right now we simply deactivate that box #}{% endblock %} +{% block avanzu_sidebar_nav %}{{ parent() }}{% endblock %} + +{% block avanzu_breadcrumb %}{{ parent() }}{% endblock %} +{% block avanzu_control_sidebar %}{{ parent() }}{% endblock %} +{% block avanzu_javascripts %}{{ parent() }}{% endblock %} +{% block avanzu_javascripts_inline %}{{ parent() }}{% endblock %} diff --git a/app/Resources/views/default/_flash_messages.html.twig b/app/Resources/views/default/_flash_messages.html.twig index 2ea971e1..33fc3476 100644 --- a/app/Resources/views/default/_flash_messages.html.twig +++ b/app/Resources/views/default/_flash_messages.html.twig @@ -1,16 +1,9 @@ -{% if app.session.started and app.session.flashBag.peekAll is not empty %} -
- {% for type, messages in app.session.flashBag.all %} - {% for message in messages %} - {# Bootstrap alert, see http://getbootstrap.com/components/#alerts #} - - {% endfor %} - {% endfor %} +{% if app.session.started and app.session.flashbag.peekAll|length > 0 %} +
+
+ {{ macro.session_flash(avanzu_adminlte_close_alert|default(true), false, '', 'flashmessages') }} +
{% endif %} diff --git a/app/Resources/views/security/login.html.twig b/app/Resources/views/security/login.html.twig index 3b8d47cf..46804f4f 100644 --- a/app/Resources/views/security/login.html.twig +++ b/app/Resources/views/security/login.html.twig @@ -34,22 +34,4 @@
-{% endblock %} - -{% block javascripts %} - {{ parent() }} - - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/Resources/views/user/profile.html.twig b/app/Resources/views/user/profile.html.twig index 2095d5c9..bc442c62 100644 --- a/app/Resources/views/user/profile.html.twig +++ b/app/Resources/views/user/profile.html.twig @@ -6,8 +6,6 @@ {% block main %} {% import _self as widgets %} - {{ include('default/_flash_messages.html.twig') }} -
{{ widgets.profile_box(user, stats) }} diff --git a/app/config/config.yml b/app/config/config.yml index 3b42a6a9..d7a4a217 100644 --- a/app/config/config.yml +++ b/app/config/config.yml @@ -42,7 +42,6 @@ framework: engines: ['twig'] default_locale: "%locale%" trusted_hosts: ~ - trusted_proxies: ~ session: handler_id: session.handler.native_file save_path: "%kernel.root_dir%/../var/sessions/%kernel.environment%" @@ -59,8 +58,9 @@ twig: # - "form/fields.html.twig" globals: kimai_context: - date_1: "d.m.Y" # used for display in timesheets - box_color: "green" + date_1: "d.m.Y" # used for display in timesheets + box_color: "green" # a color for ??? + active_warning: 3 # display a warning color if the user has at least X active recordings # Assetic Configuration (used for managing web assets: CSS, JavaScript, Sass, etc.) #assetic: @@ -100,4 +100,5 @@ avanzu_admin_theme: boxed_layout : false collapsed_sidebar: true mini_sidebar : true + # TODO support options like: language and skin via sidebar menu control_sidebar : false diff --git a/app/config/config_dev.yml b/app/config/config_dev.yml index dcee1614..29e874cd 100644 --- a/app/config/config_dev.yml +++ b/app/config/config_dev.yml @@ -43,3 +43,7 @@ services: avanzu_admin_theme: enable_demo: false + +web_profiler: + toolbar: true + intercept_redirects: false diff --git a/app/config/routing_dev.yml b/app/config/routing_dev.yml index 252828ee..5b82adff 100644 --- a/app/config/routing_dev.yml +++ b/app/config/routing_dev.yml @@ -1,12 +1,12 @@ # this imports the routes used to display the web debug toolbar at the bottom of each page -#_wdt: -# resource: "@WebProfilerBundle/Resources/config/routing/wdt.xml" -# prefix: /_wdt +_wdt: + resource: "@WebProfilerBundle/Resources/config/routing/wdt.xml" + prefix: /_wdt # this imports the routes needed to display the Symfony Profiler information -#_profiler: -# resource: "@WebProfilerBundle/Resources/config/routing/profiler.xml" -# prefix: /_profiler +_profiler: + resource: "@WebProfilerBundle/Resources/config/routing/profiler.xml" + prefix: /_profiler # this imports the route used to test error pages. Just browse the following URL: # /{_locale}/_error/{status_code}.{format} diff --git a/app/config/security.yml b/app/config/security.yml index bdd29fc2..5a188796 100644 --- a/app/config/security.yml +++ b/app/config/security.yml @@ -17,6 +17,9 @@ security: # this firewall applies to all URLs pattern: ^/ + # does what it says + logout_on_user_change: true + # but the firewall does not require login on every page # denying access is done in access_control or in your controllers anonymous: ~ diff --git a/app/config/services.yml b/app/config/services.yml index c0b7dd1d..9367f7fc 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -1,6 +1,4 @@ services: - slugger: - class: AppBundle\Utils\Slugger markdown: class: AppBundle\Utils\Markdown @@ -31,7 +29,7 @@ services: class: AppBundle\EventListener\MenuBuilder arguments: ["@event_dispatcher", "@security.authorization_checker"] tags: - - { name: kernel.event_listener, event:theme.sidebar_setup_menu, method:onSetupNavbar } + - { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupNavbar } # service that prefixes every database table app.tableprefix_subscriber: @@ -40,6 +38,7 @@ services: tags: - { name: doctrine.event_subscriber } + # additional menu entries for the timesheet bundle timesheet.configure_menu_listener: class: TimesheetBundle\EventListener\Menu tags: diff --git a/composer.json b/composer.json index 8d394ca4..3b49f1e3 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "sensio/distribution-bundle": "^5.0", "sensio/framework-extra-bundle": "^3.0", "symfony/assetic-bundle": "^2.8", - "symfony/monolog-bundle": "^2.8", + "symfony/monolog-bundle": "^3.1", "symfony/swiftmailer-bundle": "^2.3", "symfony/symfony": "~3.4", "twig/extensions": "^1.3", diff --git a/composer.lock b/composer.lock index 1247c660..910e0d14 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "5501e716ff6eaf23c5ae63019424fcfe", + "content-hash": "9be870d4b37b8cc29103dbe2cc2d02e6", "packages": [ { "name": "almasaeed2010/adminlte", @@ -2262,41 +2262,44 @@ }, { "name": "symfony/monolog-bundle", - "version": "v2.12.1", + "version": "v3.1.2", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "b0146bdca7ba2a65f3bbe7010423c7393b29ec3f" + "reference": "2b41b8b6d2c6edb1a5494f02f8e4129be2a44784" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/b0146bdca7ba2a65f3bbe7010423c7393b29ec3f", - "reference": "b0146bdca7ba2a65f3bbe7010423c7393b29ec3f", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/2b41b8b6d2c6edb1a5494f02f8e4129be2a44784", + "reference": "2b41b8b6d2c6edb1a5494f02f8e4129be2a44784", "shasum": "" }, "require": { - "monolog/monolog": "~1.18", + "monolog/monolog": "~1.22", "php": ">=5.3.2", - "symfony/config": "~2.3|~3.0", - "symfony/dependency-injection": "~2.3|~3.0", - "symfony/http-kernel": "~2.3|~3.0", - "symfony/monolog-bridge": "~2.3|~3.0" + "symfony/config": "~2.7|~3.0|~4.0", + "symfony/dependency-injection": "~2.7|~3.0|~4.0", + "symfony/http-kernel": "~2.7|~3.0|~4.0", + "symfony/monolog-bridge": "~2.7|~3.0|~4.0" }, "require-dev": { - "phpunit/phpunit": "^4.8", - "symfony/console": "~2.3|~3.0", - "symfony/yaml": "~2.3|~3.0" + "symfony/console": "~2.3|~3.0|~4.0", + "symfony/phpunit-bridge": "^3.3|^4.0", + "symfony/yaml": "~2.3|~3.0|~4.0" }, "type": "symfony-bundle", "extra": { "branch-alias": { - "dev-master": "2.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { "Symfony\\Bundle\\MonologBundle\\": "" - } + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2318,7 +2321,7 @@ "log", "logging" ], - "time": "2017-01-02T19:04:26+00:00" + "time": "2017-11-06T16:02:17+00:00" }, { "name": "symfony/polyfill-apcu", diff --git a/src/AppBundle/Controller/AbstractController.php b/src/AppBundle/Controller/AbstractController.php new file mode 100644 index 00000000..9c608cf2 --- /dev/null +++ b/src/AppBundle/Controller/AbstractController.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AppBundle\Controller; + +use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Bundle\FrameworkBundle\Controller\Controller; + +/** + * The abstract base controller. + * + * @author Kevin Papst + */ +abstract class AbstractController extends Controller +{ + const FLASH_SUCCESS = 'success'; + const FLASH_WARNING = 'warning'; + const FLASH_ERROR = 'error'; + + const DOMAIN_FLASH = 'flashmessages'; + const DOMAIN_ERROR = 'exceptions'; + + const ROLE_ADMIN = 'ROLE_ADMIN'; + + /** + * @return object|\Symfony\Component\Translation\DataCollectorTranslator|\Symfony\Component\Translation\IdentityTranslator + */ + protected function getTranslator() + { + return $this->container->get('translator'); + } + + /** + * A translated helper for denyAccessUnlessGranted() + * + * @param $attributes + * @param null $subject + * @param string $translationKey + * @param array $parameter + * @throws AccessDeniedException + */ + protected function denyUnlessGranted($attributes, $subject = null, $translationKey = 'access.denied', $parameter = []) + { + $error = $this->getTranslator()->trans($translationKey, $parameter, self::DOMAIN_ERROR); + // TODO try & catch and add to audit log? + $this->denyAccessUnlessGranted($attributes, $subject, $error); + } + + /** + * Adds a "successful" flash message to the stack. + * + * @param string $translationKey + * @param array $parameter + */ + protected function flashSuccess($translationKey, $parameter = []) + { + if (!empty($parameter)) { + $translationKey = $this->getTranslator()->trans( + $translationKey, + $parameter, + self::DOMAIN_FLASH + ); + } + + $this->addFlash(self::FLASH_SUCCESS, $translationKey); + } + + /** + * Adds a "warning" flash message to the stack. + * + * @param $translationKey + * @param array $parameter + */ + protected function flashWarning($translationKey, $parameter = []) + { + if (!empty($parameter)) { + $translationKey = $this->getTranslator()->trans( + $translationKey, + $parameter, + self::DOMAIN_FLASH + ); + } + + $this->addFlash(self::FLASH_WARNING, $translationKey); + } + + /** + * Adds a "error" flash message to the stack. + * + * @param $translationKey + * @param array $parameter + */ + protected function flashError($translationKey, $parameter = []) + { + if (!empty($parameter)) { + $translationKey = $this->getTranslator()->trans( + $translationKey, + $parameter, + self::DOMAIN_FLASH + ); + } + + $this->addFlash(self::FLASH_ERROR, $translationKey); + } +} diff --git a/src/AppBundle/Controller/ProfileController.php b/src/AppBundle/Controller/ProfileController.php index d05ce9a1..3c18dffc 100644 --- a/src/AppBundle/Controller/ProfileController.php +++ b/src/AppBundle/Controller/ProfileController.php @@ -33,7 +33,7 @@ use Symfony\Component\HttpFoundation\Request; * * @author Kevin Papst */ -class ProfileController extends Controller +class ProfileController extends AbstractController { /** * @Route("/{username}", name="user_profile") @@ -48,10 +48,11 @@ class ProfileController extends Controller /** * @param User $user - * @param Form $editForm - * @param Form $pwdForm + * @param Form|null $editForm + * @param Form|null $pwdForm * @param string $tab * @return \Symfony\Component\HttpFoundation\Response + * @throws \Doctrine\ORM\NonUniqueResultException */ protected function getProfileView(User $user, Form $editForm = null, Form $pwdForm = null, $tab = 'charts') { @@ -105,7 +106,7 @@ class ProfileController extends Controller $entityManager->persist($user); $entityManager->flush(); - $this->addFlash('success', 'action.updated_successfully'); + $this->flashSuccess('action.updated_successfully'); return $this->redirectToRoute( 'user_profile', ['username' => $user->getUsername()] @@ -135,7 +136,7 @@ class ProfileController extends Controller $entityManager->persist($user); $entityManager->flush(); - $this->addFlash('success', 'action.updated_successfully'); + $this->flashSuccess('action.updated_successfully'); return $this->redirectToRoute( 'user_profile', ['username' => $user->getUsername()] @@ -146,7 +147,7 @@ class ProfileController extends Controller } /** - * FIXME + * FIXME implement profile deletion * @Route("/{username}/delete", name="user_profile_delete") * @Method({"GET", "POST"}) */ @@ -174,7 +175,7 @@ class ProfileController extends Controller // only administrator can bypass that part if the requested user is not the current user if ($username !== $user->getUsername()) { - $this->denyAccessUnlessGranted('ROLE_ADMIN', null, 'Unable to access this page'); // TODO translation + $this->denyUnlessGranted('ROLE_ADMIN'); } // if the user is not the current use, load the requested one @@ -192,7 +193,7 @@ class ProfileController extends Controller /** * @param User $user - * @return \Symfony\Component\Form\Form + * @return \Symfony\Component\Form\FormInterface */ private function createEditForm(User $user) { @@ -208,7 +209,7 @@ class ProfileController extends Controller /** * @param User $user - * @return \Symfony\Component\Form\Form + * @return \Symfony\Component\Form\FormInterface */ private function createPasswordForm(User $user) { @@ -225,8 +226,7 @@ class ProfileController extends Controller /** * @param User $user - * - * @return \Symfony\Component\Form\Form The form + * @return \Symfony\Component\Form\FormInterface */ private function createDeleteForm(User $user) { diff --git a/src/AppBundle/Tests/Controller/DefaultControllerTest.php b/src/AppBundle/Tests/Controller/DefaultControllerTest.php index a23c2bfd..240f73b1 100644 --- a/src/AppBundle/Tests/Controller/DefaultControllerTest.php +++ b/src/AppBundle/Tests/Controller/DefaultControllerTest.php @@ -14,7 +14,7 @@ namespace AppBundle\Tests\Controller; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; /** - * FIXME CAN BE REMOVED + * TODO adjust to actual app * * Functional test that implements a "smoke test" of all the public and secure * URLs of the application. diff --git a/src/AppBundle/Tests/Utils/SluggerTest.php b/src/AppBundle/Tests/Utils/SluggerTest.php deleted file mode 100644 index 62544a1d..00000000 --- a/src/AppBundle/Tests/Utils/SluggerTest.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Tests\Utils; - -use AppBundle\Utils\Slugger; - -/** - * FIXME CAN BE REMOVED - * - * Unit test for the application utils. - * See http://symfony.com/doc/current/book/testing.html#unit-tests - * - * Execute the application tests using this command (requires PHPUnit to be installed): - * - * $ cd your-symfony-project/ - * $ phpunit -c app - * - */ -class SluggerTest extends \PHPUnit_Framework_TestCase -{ - /** - * @dataProvider getSlugs - */ - public function testSlugify($string, $slug) - { - $slugger = new Slugger(); - $result = $slugger->slugify($string); - - $this->assertEquals($slug, $result); - } - - public function getSlugs() - { - yield ['Lorem Ipsum' , 'lorem-ipsum']; - yield [' Lorem Ipsum ' , 'lorem-ipsum']; - yield [' lOrEm iPsUm ' , 'lorem-ipsum']; - yield ['!Lorem Ipsum!' , 'lorem-ipsum']; - yield ['lorem-ipsum' , 'lorem-ipsum']; - } -} diff --git a/src/AppBundle/Twig/Extensions.php b/src/AppBundle/Twig/Extensions.php index 061a9fd5..e9fa19ab 100644 --- a/src/AppBundle/Twig/Extensions.php +++ b/src/AppBundle/Twig/Extensions.php @@ -15,6 +15,8 @@ use AppBundle\Utils\Markdown; use Symfony\Component\Intl\Intl; use DateTime; use DateInterval; +use TimesheetBundle\Entity\Customer; +use TimesheetBundle\Entity\Timesheet; /** * Multiple Twig extensions: filters and functions @@ -52,7 +54,10 @@ class Extensions extends \Twig_Extension return [ new \Twig_SimpleFilter('md2html', [$this, 'markdownToHtml'], ['is_safe' => ['html']]), new \Twig_SimpleFilter('duration', array($this, 'duration')), + new \Twig_SimpleFilter('durationForEntry', array($this, 'durationForEntry')), new \Twig_SimpleFilter('money', array($this, 'money')), + new \Twig_SimpleFilter('currency', array($this, 'currency')), + new \Twig_SimpleFilter('country', array($this, 'country')), ]; } @@ -66,6 +71,18 @@ class Extensions extends \Twig_Extension ]; } + /** + * Returns the formatted duration for a Timesheet entry. + * + * @param Timesheet $entry + * @param bool $includeSeconds + * @return string + */ + public function durationForEntry(Timesheet $entry, $includeSeconds = false) + { + return $this->duration($entry->getDuration(), $includeSeconds); + } + /** * Transforms seconds into a duration string. * @@ -91,13 +108,32 @@ class Extensions extends \Twig_Extension return $hour . ':' . $minute . ':' . $second . ' h'; } + /** + * @param string $currency + * @return string + */ + public function currency($currency) + { + return Intl::getCurrencyBundle()->getCurrencySymbol($currency); + } + + /** + * @param string $country + * @return string + */ + public function country($country) + { + return Intl::getRegionBundle()->getCountryName($country); + } + /** * @param float $amount * @param string $currency * @return string */ - public function money($amount, $currency = 'EUR') + public function money($amount, $currency = null) { + $currency = $currency ?: Customer::DEFAULT_CURRENCY; return round($amount) . ' ' . Intl::getCurrencyBundle()->getCurrencySymbol($currency); } diff --git a/src/AppBundle/Utils/Slugger.php b/src/AppBundle/Utils/Slugger.php deleted file mode 100644 index 58fc66da..00000000 --- a/src/AppBundle/Utils/Slugger.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace AppBundle\Utils; - -/** - * FIXME CAN BE REMOVED - * - * This class is used to provide an example of integrating simple classes as - * services into a Symfony application. - * - * @author Ryan Weaver - * @author Javier Eguiluz - */ -class Slugger -{ - /** - * @param string $string - * - * @return string - */ - public function slugify($string) - { - return trim(preg_replace('/[^a-z0-9]+/', '-', strtolower(strip_tags($string))), '-'); - } -} diff --git a/src/TimesheetBundle/Controller/ActivityController.php b/src/TimesheetBundle/Controller/ActivityController.php new file mode 100644 index 00000000..17d703ec --- /dev/null +++ b/src/TimesheetBundle/Controller/ActivityController.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace TimesheetBundle\Controller; + +use Symfony\Bundle\FrameworkBundle\Controller\Controller; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use TimesheetBundle\Entity\Activity; +use TimesheetBundle\Repository\ActivityRepository; + +/** + * Controller used to manage activity contents in the public part of the site. + * + * @Route("/activity") + * @Security("has_role('ROLE_USER')") + * + * @author Kevin Papst + */ +class ActivityController extends Controller +{ + + /** + * @return ActivityRepository + */ + protected function getRepository() + { + return $this->getDoctrine()->getRepository(Activity::class); + } + + /** + * The flyout to render recent activities and quick-start new recordings. + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function recentActivitiesAction() + { + $user = $this->getUser(); + $activeEntries = $this->getRepository()->getRecentActivities($user, new \DateTime('-30 days')); // TODO make days configurable + + return $this->render( + 'TimesheetBundle:Navbar:recent-activities.html.twig', + ['activities' => $activeEntries] + ); + } +} diff --git a/src/TimesheetBundle/Controller/Admin/ActivityController.php b/src/TimesheetBundle/Controller/Admin/ActivityController.php index a93f50d9..36f6af10 100644 --- a/src/TimesheetBundle/Controller/Admin/ActivityController.php +++ b/src/TimesheetBundle/Controller/Admin/ActivityController.php @@ -11,10 +11,11 @@ namespace TimesheetBundle\Controller\Admin; +use AppBundle\Controller\AbstractController; +use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use TimesheetBundle\Entity\Activity; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; @@ -30,7 +31,7 @@ use TimesheetBundle\Repository\ActivityRepository; * * @author Kevin Papst */ -class ActivityController extends Controller +class ActivityController extends AbstractController { /** * @Route("/", defaults={"page": 1}, name="admin_activity") @@ -50,9 +51,8 @@ class ActivityController extends Controller * @Route("/{id}/edit", name="admin_activity_edit") * @Method({"GET", "POST"}) */ - public function editAction($id, Request $request) + public function editAction(Activity $activity, Request $request) { - $activity = $this->getById($id); $editForm = $this->createEditForm($activity); $editForm->handleRequest($request); @@ -62,7 +62,7 @@ class ActivityController extends Controller $entityManager->persist($activity); $entityManager->flush(); - $this->addFlash('success', 'action.updated_successfully'); + $this->flashSuccess('action.updated_successfully'); return $this->redirectToRoute( 'admin_activity', ['id' => $activity->getId()] @@ -78,21 +78,6 @@ class ActivityController extends Controller ); } - /** - * @param $id - * @return null|Activity - */ - protected function getById($id) - { - /* @var $repo ActivityRepository */ - $repo = $this->getDoctrine()->getRepository(Activity::class); - $activity = $repo->getById($id); - if (null === $activity) { - throw new NotFoundHttpException('Activity "'.$id.'" does not exist'); - } - return $activity; - } - /** * @param Activity $activity * @return \Symfony\Component\Form\Form diff --git a/src/TimesheetBundle/Controller/Admin/CustomerController.php b/src/TimesheetBundle/Controller/Admin/CustomerController.php index edcd09a9..09a855fe 100644 --- a/src/TimesheetBundle/Controller/Admin/CustomerController.php +++ b/src/TimesheetBundle/Controller/Admin/CustomerController.php @@ -11,10 +11,11 @@ namespace TimesheetBundle\Controller\Admin; +use AppBundle\Controller\AbstractController; +use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use TimesheetBundle\Entity\Customer; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; @@ -30,7 +31,7 @@ use TimesheetBundle\Repository\CustomerRepository; * * @author Kevin Papst */ -class CustomerController extends Controller +class CustomerController extends AbstractController { /** * @Route("/", defaults={"page": 1}, name="admin_customer") @@ -49,50 +50,38 @@ class CustomerController extends Controller /** * @Route("/{id}/edit", name="admin_customer_edit") * @Method({"GET", "POST"}) + * + * @param Customer $customer + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ - public function editAction($id, Request $request) + public function editAction(Customer $customer, Request $request) { - $entity = $this->getById($id); - $editForm = $this->createEditForm($entity); + $editForm = $this->createEditForm($customer); $editForm->handleRequest($request); if ($editForm->isSubmitted() && $editForm->isValid()) { $entityManager = $this->getDoctrine()->getManager(); - $entityManager->persist($entity); + $entityManager->persist($customer); $entityManager->flush(); - $this->addFlash('success', 'action.updated_successfully'); + $this->flashSuccess('action.updated_successfully'); return $this->redirectToRoute( - 'admin_customer', ['id' => $entity->getId()] + 'admin_customer', ['id' => $customer->getId()] ); } return $this->render( 'TimesheetBundle:admin:customer_edit.html.twig', [ - 'customer' => $entity, + 'customer' => $customer, 'form' => $editForm->createView() ] ); } - /** - * @param $id - * @return null|Customer - */ - protected function getById($id) - { - /* @var $repo CustomerRepository */ - $repo = $this->getDoctrine()->getRepository(Customer::class); - $activity = $repo->getById($id); - if (null === $activity) { - throw new NotFoundHttpException('Customer "'.$id.'" does not exist'); - } - return $activity; - } - /** * @param Customer $customer * @return \Symfony\Component\Form\Form diff --git a/src/TimesheetBundle/Controller/Admin/ProjectController.php b/src/TimesheetBundle/Controller/Admin/ProjectController.php index 1c8fec7f..93d08222 100644 --- a/src/TimesheetBundle/Controller/Admin/ProjectController.php +++ b/src/TimesheetBundle/Controller/Admin/ProjectController.php @@ -11,10 +11,10 @@ namespace TimesheetBundle\Controller\Admin; +use AppBundle\Controller\AbstractController; use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\Request; use TimesheetBundle\Entity\Project; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; @@ -30,7 +30,7 @@ use TimesheetBundle\Repository\ProjectRepository; * * @author Kevin Papst */ -class ProjectController extends Controller +class ProjectController extends AbstractController { /** * @Route("/", defaults={"page": 1}, name="admin_project") @@ -53,13 +53,12 @@ class ProjectController extends Controller * @Route("/{id}/edit", name="admin_project_edit") * @Method({"GET", "POST"}) * - * @param $id + * @param Project $project * @param Request $request * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ - public function editAction($id, Request $request) + public function editAction(Project $project, Request $request) { - $project = $this->getById($id); $editForm = $this->createEditForm($project); $editForm->handleRequest($request); @@ -69,7 +68,7 @@ class ProjectController extends Controller $entityManager->persist($project); $entityManager->flush(); - $this->addFlash('success', 'action.updated_successfully'); + $this->flashSuccess('action.updated_successfully'); return $this->redirectToRoute( 'admin_project', ['id' => $project->getId()] @@ -85,24 +84,9 @@ class ProjectController extends Controller ); } - /** - * @param $id - * @return null|Project - */ - protected function getById($id) - { - /* @var $repo ProjectRepository */ - $repo = $this->getDoctrine()->getRepository(Project::class); - $activity = $repo->getById($id); - if (null === $activity) { - throw new NotFoundHttpException('Project "'.$id.'" does not exist'); - } - return $activity; - } - /** * @param Project $project - * @return \Symfony\Component\Form\Form + * @return \Symfony\Component\Form\FormInterface */ private function createEditForm(Project $project) { @@ -112,7 +96,7 @@ class ProjectController extends Controller [ 'action' => $this->generateUrl('admin_project_edit', ['id' => $project->getId()]), 'method' => 'POST', - 'currency' => $project->getCurrency() + 'currency' => $project->getCustomer()->getCurrency() ] ); } diff --git a/src/TimesheetBundle/Controller/TimesheetController.php b/src/TimesheetBundle/Controller/TimesheetController.php index 6c3ae322..702e2e77 100644 --- a/src/TimesheetBundle/Controller/TimesheetController.php +++ b/src/TimesheetBundle/Controller/TimesheetController.php @@ -11,12 +11,17 @@ namespace TimesheetBundle\Controller; +use AppBundle\Controller\AbstractController; +use Pagerfanta\Pagerfanta; +use TimesheetBundle\Entity\Activity; use TimesheetBundle\Entity\Timesheet; -use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; +use Symfony\Component\HttpFoundation\Request; +use TimesheetBundle\Form\TimesheetEditForm; +use TimesheetBundle\Repository\TimesheetRepository; /** * Controller used to manage timesheet contents in the public part of the site. @@ -26,8 +31,16 @@ use Sensio\Bundle\FrameworkExtraBundle\Configuration\Cache; * * @author Kevin Papst */ -class TimesheetController extends Controller +class TimesheetController extends AbstractController { + /** + * @return TimesheetRepository + */ + protected function getRepository() + { + return $this->getDoctrine()->getRepository(Timesheet::class); + } + /** * @Route("/", defaults={"page": 1}, name="timesheet") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="timesheet_paginated") @@ -38,20 +51,144 @@ class TimesheetController extends Controller { $user = $this->getUser(); /* @var $entries Pagerfanta */ - $entries = $this->getDoctrine()->getRepository(Timesheet::class)->findLatest($user, $page); + $entries = $this->getRepository()->findLatest($user, $page); - return $this->render('TimesheetBundle:timesheet:index.html.twig', ['entries' => $entries]); + return $this->render('TimesheetBundle:timesheet:index.html.twig', [ + 'entries' => $entries, + 'page' => $page + ]); } - public function statusEntryAction() + /** + * The "main button and flyout" for displaying (and stopping) active entries. + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function activeEntriesAction() { $user = $this->getUser(); - $activeEntry = $this->getDoctrine()->getRepository(Timesheet::class)->getActiveEntry($user); + $activeEntries = $this->getRepository()->getActiveEntries($user); - $activeEntry = null; return $this->render( - 'TimesheetBundle:Sidebar:navbar-panel.html.twig', - ['entry' => $activeEntry] + 'TimesheetBundle:Navbar:active-entries.html.twig', + ['entries' => $activeEntries] + ); + } + + /** + * The route to stop a running entry. + * + * @Route("/{id}/stop", name="timesheet_stop") + * @Method({"GET"}) + * + * @param Timesheet $entry + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function stopAction(Timesheet $entry, Request $request) + { + $user = $this->getUser(); + + // make sure only ADMIN can stop other users entries + if ($user->getId() !== $entry->getUser()->getId()) { + $this->denyUnlessGranted('ROLE_ADMIN', null, 'timesheet.access.denied', ['%user%' => $user->getId(), '%entry%' => $entry->getId()]); + } + + try { + $this->getRepository()->stopRecording($entry); + $this->flashSuccess('timesheet.stop.success'); + } catch (\Exception $ex) { + $this->flashError('timesheet.stop.error', ['%reason%' => $ex->getMessage()]); + } + + return $this->redirectToRoute('timesheet'); + } + + /** + * The route to stop a running entry. + * + * @Route("/start/{id}", name="timesheet_start", requirements={"id" = "\d+"}) + * @Method({"GET", "POST"}) + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function startAction(Activity $activity, Request $request) + { + $user = $this->getUser(); + + try { + $this->getRepository()->startRecording($user, $activity); + $this->flashSuccess('timesheet.start.success'); + } catch (\Exception $ex) { + $this->flashError('timesheet.start.error', ['%reason%' => $ex->getMessage()]); + } + + return $this->redirectToRoute('timesheet'); + } + + /** + * The route to edit an existing entry or to create a complete new entry. + * + * @Route("/{id}/edit", name="timesheet_edit") + * @Method({"GET", "POST"}) + * + * @param Timesheet $entry + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + public function editAction(Timesheet $entry, Request $request) + { + $user = $this->getUser(); + + // make sure only ADMIN can edit other users entries + if ($user->getId() !== $entry->getUser()->getId()) { + $this->denyUnlessGranted('ROLE_ADMIN', null, 'timesheet.access.denied', ['%user%' => $user->getId(), '%entry%' => $entry->getId()]); + } + + $editForm = $this->createEditForm($entry, $request->get('page')); + + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($entry); + $entityManager->flush(); + + $this->flashSuccess('action.updated_successfully'); + + return $this->redirectToRoute( + 'timesheet_paginated', ['page' => $request->get('page')] + ); + } + + return $this->render( + 'TimesheetBundle:timesheet:edit.html.twig', + [ + 'entry' => $entry, + 'form' => $editForm->createView(), + ] + ); + } + + /** + * @param Timesheet $entry + * @param string $page + * @return \Symfony\Component\Form\Form + */ + private function createEditForm(Timesheet $entry, $page) + { + return $this->createForm( + TimesheetEditForm::class, + $entry, + [ + 'action' => $this->generateUrl('timesheet_edit', [ + 'id' => $entry->getId(), + 'page' => $page + ]), + 'method' => 'POST', + 'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(), + ] ); } } diff --git a/src/TimesheetBundle/DataFixtures/ORM/LoadFixtures.php b/src/TimesheetBundle/DataFixtures/ORM/LoadFixtures.php index 94b25377..05865645 100644 --- a/src/TimesheetBundle/DataFixtures/ORM/LoadFixtures.php +++ b/src/TimesheetBundle/DataFixtures/ORM/LoadFixtures.php @@ -152,15 +152,17 @@ class LoadFixtures extends AppBundleLoadFixtures $rate = rand(self::RATE_MIN, self::RATE_MAX); $entry = new Timesheet(); - $entry->setActivity($activity); - $entry->setDescription($this->getRandomPhrase()); - $entry->setUser($user); - $entry->setRate(round(($duration / 3600) * $rate)); - $entry->setBegin($start); + $entry + ->setActivity($activity) + ->setDescription($this->getRandomPhrase()) + ->setUser($user) + ->setRate(round(($duration / 3600) * $rate)) + ->setBegin($start); if ($setEndDate) { - $entry->setEnd($end); - $entry->setDuration($duration); + $entry + ->setEnd($end) + ->setDuration($duration); } return $entry; @@ -176,11 +178,14 @@ class LoadFixtures extends AppBundleLoadFixtures for ($i = 0; $i < $amountCustomer; $i++) { $entry = new Customer(); - $entry->setName($allCustomer[$i]); - $entry->setCity($this->getRandomLocation()); - $entry->setComment($this->getRandomPhrase()); - $entry->setVisible($i % 3 != 0); - $entry->setTimezone($allTimezones[rand(1, $amountTimezone)]); + $entry + ->setCurrency($this->getRandomCurrency()) + ->setVat(rand(0, 30)) + ->setName($allCustomer[$i]) + ->setAddress($this->getRandomLocation()) + ->setComment($this->getRandomPhrase()) + ->setVisible($i % 3 != 0) + ->setTimezone($allTimezones[rand(1, $amountTimezone)]); $manager->persist($entry); } @@ -195,12 +200,12 @@ class LoadFixtures extends AppBundleLoadFixtures for ($i = 0; $i < $amountCustomer * 2; $i++) { $entry = new Project(); - $entry->setName($this->getRandomProject()); - $entry->setCurrency($this->getRandomCurrency()); - $entry->setBudget(rand(1000, 100000)); - $entry->setComment($this->getRandomPhrase()); - $entry->setCustomer($allCustomer[($i % $amountCustomer) + 1]); - $entry->setVisible($i % 3 != 0); + $entry + ->setName($this->getRandomProject()) + ->setBudget(rand(1000, 100000)) + ->setComment($this->getRandomPhrase()) + ->setCustomer($allCustomer[($i % $amountCustomer) + 1]) + ->setVisible($i % 3 != 0); $manager->persist($entry); } @@ -215,10 +220,11 @@ class LoadFixtures extends AppBundleLoadFixtures $activityCount = rand(1, self::AMOUNT_ACTIVITIES); for ($i = 0; $i < $activityCount; $i++) { $entry = new Activity(); - $entry->setProject($project); - $entry->setName($this->getRandomActivity()); - $entry->setComment($this->getRandomPhrase()); - $entry->setVisible($i % 3 != 0); + $entry + ->setProject($project) + ->setName($this->getRandomActivity()) + ->setComment($this->getRandomPhrase()) + ->setVisible($i % 3 != 0); $manager->persist($entry); } diff --git a/src/TimesheetBundle/Entity/Activity.php b/src/TimesheetBundle/Entity/Activity.php index da068120..ee16e260 100644 --- a/src/TimesheetBundle/Entity/Activity.php +++ b/src/TimesheetBundle/Entity/Activity.php @@ -70,18 +70,20 @@ class Activity } /** - * @param int $project + * @param Project $project + * @return Activity */ public function setProject($project) { $this->project = $project; + + return $this; } /** * Set name * * @param string $name - * * @return Activity */ public function setName($name) @@ -105,13 +107,11 @@ class Activity * Set comment * * @param string $comment - * * @return Activity */ public function setComment($comment) { $this->comment = $comment; - return $this; } @@ -150,7 +150,7 @@ class Activity } /** - * Get activityid + * Get activity id * * @return integer */ diff --git a/src/TimesheetBundle/Entity/Customer.php b/src/TimesheetBundle/Entity/Customer.php index 39a546cd..c2538d84 100644 --- a/src/TimesheetBundle/Entity/Customer.php +++ b/src/TimesheetBundle/Entity/Customer.php @@ -25,6 +25,8 @@ use Symfony\Component\Validator\Constraints as Assert; class Customer { + const DEFAULT_CURRENCY = 'EUR'; + /** * @var integer * @@ -100,6 +102,13 @@ class Customer */ private $country; + /** + * @var string + * + * @ORM\Column(name="currency", type="string", length=3, nullable=false) + */ + private $currency = self::DEFAULT_CURRENCY; + /** * @var string * @@ -339,6 +348,25 @@ class Customer return $this->country; } + /** + * @return string + */ + public function getCurrency() + { + return $this->currency; + } + + /** + * @param string $currency + * @return $this + */ + public function setCurrency($currency) + { + $this->currency = $currency; + + return $this; + } + /** * Set phone * diff --git a/src/TimesheetBundle/Entity/Project.php b/src/TimesheetBundle/Entity/Project.php index 2eca9ecc..8deba1ab 100644 --- a/src/TimesheetBundle/Entity/Project.php +++ b/src/TimesheetBundle/Entity/Project.php @@ -64,17 +64,10 @@ class Project /** * @var string * - * @ORM\Column(name="budget", type="decimal", precision=10, scale=2, nullable=true) + * @ORM\Column(name="budget", type="decimal", precision=10, scale=2, nullable=false) */ private $budget = 0.00; - /** - * @var string - * - * @ORM\Column(name="currency", type="string", length=3, nullable=false) - */ - private $currency = 'EUR'; - /** * @var Activity[] * @@ -92,25 +85,6 @@ class Project return $this->id; } - /** - * @return string - */ - public function getCurrency() - { - return $this->currency; - } - - /** - * @param string $currency - * @return $this - */ - public function setCurrency($currency) - { - $this->currency = $currency; - - return $this; - } - /** * @return Customer */ diff --git a/src/TimesheetBundle/Entity/Timesheet.php b/src/TimesheetBundle/Entity/Timesheet.php index e13143c6..0541ad75 100644 --- a/src/TimesheetBundle/Entity/Timesheet.php +++ b/src/TimesheetBundle/Entity/Timesheet.php @@ -64,7 +64,7 @@ class Timesheet private $user; /** - * @var integer + * @var Activity * * @ORM\ManyToOne(targetEntity="TimesheetBundle\Entity\Activity") * @ORM\JoinColumn(name="activity", referencedColumnName="id") @@ -128,6 +128,10 @@ class Timesheet public function setEnd($end) { $this->end = $end; + if ($end === null) { + $this->duration = 0; + } + return $this; } @@ -151,12 +155,16 @@ class Timesheet */ public function getDuration() { - if ($this->duration !== 0 || $this->begin === null) { - return $this->duration; + if ($this->begin === null) { + return 0; } - $current = new \DateTime(); - return $current->getTimestamp() - $this->begin->getTimestamp(); + if ($this->end === null) { + $current = new \DateTime(); + return $current->getTimestamp() - $this->begin->getTimestamp(); + } + + return $this->duration; } /** @@ -183,9 +191,9 @@ class Timesheet } /** - * Set activityid + * Set activity * - * @param integer $activity + * @param Activity $activity * * @return Timesheet */ @@ -196,9 +204,9 @@ class Timesheet } /** - * Get activityid + * Get Activity * - * @return integer + * @return Activity */ public function getActivity() { diff --git a/src/TimesheetBundle/Form/CustomerEditForm.php b/src/TimesheetBundle/Form/CustomerEditForm.php index 4ed03b18..2959615b 100644 --- a/src/TimesheetBundle/Form/CustomerEditForm.php +++ b/src/TimesheetBundle/Form/CustomerEditForm.php @@ -14,6 +14,7 @@ namespace TimesheetBundle\Form; use AppBundle\Form\Type\YesNoType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CountryType; +use Symfony\Component\Form\Extension\Core\Type\CurrencyType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\PercentType; use Symfony\Component\Form\Extension\Core\Type\TelType; @@ -83,10 +84,14 @@ class CustomerEditForm extends AbstractType 'label' => 'label.address', 'required' => false, ]) - // TODO string - length 2 + // string - length 2 ->add('country', CountryType::class, [ 'label' => 'label.country', ]) + // string - length 3 + ->add('currency', CurrencyType::class, [ + 'label' => 'label.currency', + ]) // string - length 255 ->add('phone', TelType::class, [ 'label' => 'label.phone', diff --git a/src/TimesheetBundle/Form/ProjectEditForm.php b/src/TimesheetBundle/Form/ProjectEditForm.php index 2d83d1ed..93f43878 100644 --- a/src/TimesheetBundle/Form/ProjectEditForm.php +++ b/src/TimesheetBundle/Form/ProjectEditForm.php @@ -12,18 +12,18 @@ namespace TimesheetBundle\Form; use AppBundle\Form\Type\YesNoType; -use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\MoneyType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; -use Symfony\Component\Intl\Intl; use Symfony\Component\OptionsResolver\OptionsResolver; +use TimesheetBundle\Entity\Customer; use TimesheetBundle\Entity\Project; use TimesheetBundle\Form\Type\CustomerType; /** - * Defines the form used to manipulate Projects. + * Defines the form used to edit Projects. * * @author Kevin Papst */ @@ -37,7 +37,7 @@ class ProjectEditForm extends AbstractType { $builder // string - length 255 - ->add('name', null, [ + ->add('name', TextType::class, [ 'label' => 'label.name', ]) // text @@ -57,7 +57,6 @@ class ProjectEditForm extends AbstractType 'label' => 'label.budget', 'currency' => $builder->getOption('currency'), ]) - // FIXME add budget // do not allow activity selection as this causes headaches: // 1. it is a bad UX // 2. what should happen if they are detached? @@ -82,7 +81,7 @@ class ProjectEditForm extends AbstractType 'csrf_protection' => true, 'csrf_field_name' => '_token', 'csrf_token_id' => 'admin_activity_edit', - 'currency' => 'EUR,' + 'currency' => Customer::DEFAULT_CURRENCY, ]); } } diff --git a/src/TimesheetBundle/Form/TimesheetEditForm.php b/src/TimesheetBundle/Form/TimesheetEditForm.php new file mode 100644 index 00000000..bb103ea5 --- /dev/null +++ b/src/TimesheetBundle/Form/TimesheetEditForm.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace TimesheetBundle\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateTimeType; +use Symfony\Component\Form\Extension\Core\Type\MoneyType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use TimesheetBundle\Entity\Customer; +use TimesheetBundle\Entity\Timesheet; + +/** + * Defines the form used to manipulate Timesheet entries. + * + * @author Kevin Papst + */ +class TimesheetEditForm extends AbstractType +{ + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + // datetime + ->add('begin', DateTimeType::class, [ + 'label' => 'label.begin', + 'date_widget' => 'single_text', + ]) + // datetime + ->add('end', DateTimeType::class, [ + 'label' => 'label.end', + 'date_widget' => 'single_text', + 'required' => false, + ]) + // integer + /* + ->add('duration', RangeType::class, [ + 'label' => 'label.duration', + ]) + // User + ->add('user', UserType::class, [ + 'label' => 'label.user', + ]) + // Activity + ->add('activity', ActivityType::class, [ + 'label' => 'label.activity', + ]) + */ + // customer + ->add('description', TextareaType::class, [ + 'label' => 'label.description', + 'required' => false, + ]) + // string + ->add('rate', MoneyType::class, [ + 'label' => 'label.rate', + 'currency' => $builder->getOption('currency'), + ]) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Timesheet::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_timsheet_edit', + 'currency' => Customer::DEFAULT_CURRENCY, + ]); + } +} diff --git a/src/TimesheetBundle/Repository/ActivityRepository.php b/src/TimesheetBundle/Repository/ActivityRepository.php index 9423619a..47a24721 100644 --- a/src/TimesheetBundle/Repository/ActivityRepository.php +++ b/src/TimesheetBundle/Repository/ActivityRepository.php @@ -12,10 +12,10 @@ namespace TimesheetBundle\Repository; use AppBundle\Entity\User; +use Doctrine\ORM\Query; use TimesheetBundle\Entity\Activity; use TimesheetBundle\Entity\Timesheet; use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\Query; use Pagerfanta\Adapter\DoctrineORMAdapter; use Pagerfanta\Pagerfanta; use TimesheetBundle\Model\ActivityStatistic; @@ -36,11 +36,53 @@ class ActivityRepository extends EntityRepository { return $this->find($id); } - + + /** + * @param User|null $user + * @param \DateTime|null $startFrom + * @return mixed + */ + public function getRecentActivities(User $user = null, \DateTime $startFrom = null) + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb->select('t', 'a', 'p', 'c') + ->from('TimesheetBundle:Timesheet', 't') + ->join('t.activity', 'a') + ->join('a.project', 'p') + ->join('p.customer', 'c') + ->where($qb->expr()->isNotNull('t.end')) + ->groupBy('a.id') + ->orderBy('t.end', 'DESC') + ->setMaxResults(10) + ; + + if ($user !== null) { + $qb->andWhere('t.user = :user') + ->setParameter('user', $user); + } + + if ($startFrom !== null) { + $qb->andWhere($qb->expr()->gt('t.begin', ':begin')) + ->setParameter('begin', $startFrom); + } + + $results = $qb->getQuery()->getResult(); + + $activities = []; + /* @var Timesheet $entry */ + foreach($results as $entry) { + $activities[] = $entry->getActivity(); + } + + return $activities; + } + /** * Return statistic data for all user. * * @return ActivityStatistic + * @throws \Doctrine\ORM\NonUniqueResultException */ public function getGlobalStatistics() { diff --git a/src/TimesheetBundle/Repository/TimesheetRepository.php b/src/TimesheetBundle/Repository/TimesheetRepository.php index 3d9bc8a7..acac527c 100644 --- a/src/TimesheetBundle/Repository/TimesheetRepository.php +++ b/src/TimesheetBundle/Repository/TimesheetRepository.php @@ -12,6 +12,7 @@ namespace TimesheetBundle\Repository; use AppBundle\Entity\User; +use TimesheetBundle\Entity\Activity; use TimesheetBundle\Entity\Timesheet; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; @@ -32,6 +33,51 @@ use DateTime; class TimesheetRepository extends EntityRepository { + /** + * @param Timesheet $entry + * @return bool + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function stopRecording(Timesheet $entry) + { + $end = new DateTime(); + $begin = $entry->getBegin(); + + $entry->setEnd($end); + $entry->setDuration($end->getTimestamp() - $begin->getTimestamp()); + + // TODO calculate rate by users hourly rate + + $entityManager = $this->getEntityManager(); + $entityManager->persist($entry); + $entityManager->flush(); + + return true; + } + + /** + * @param User $user + * @param Activity $activity + * @return bool + * @throws \Doctrine\ORM\ORMException + * @throws \Doctrine\ORM\OptimisticLockException + */ + public function startRecording(User $user, Activity $activity) + { + $entry = new Timesheet(); + $entry + ->setBegin(new DateTime()) + ->setUser($user) + ->setActivity($activity); + + $entityManager = $this->getEntityManager(); + $entityManager->persist($entry); + $entityManager->flush(); + + return true; + } + /** * @param $select * @param User|null $user @@ -78,6 +124,7 @@ class TimesheetRepository extends EntityRepository * * @param User $user * @return TimesheetStatistic + * @throws \Doctrine\ORM\NonUniqueResultException */ public function getUserStatistics(User $user) { @@ -159,7 +206,8 @@ class TimesheetRepository extends EntityRepository /** * Fetch statistic data for all user. * - * @return TimesheetStatistic + * @return TimesheetGlobalStatistic + * @throws \Doctrine\ORM\NonUniqueResultException */ public function getGlobalStatistics() { @@ -172,7 +220,7 @@ class TimesheetRepository extends EntityRepository $userTotal = $this->getEntityManager() ->createQuery('SELECT COUNT(DISTINCT(t.user)) FROM TimesheetBundle:Timesheet t') ->getSingleScalarResult(); - $activeNow = $this->getActiveEntry(); + $activeNow = $this->getActiveEntries(); $amountMonth = $this->queryThisMonth('SUM(t.rate)') ->getQuery() ->getSingleScalarResult(); @@ -197,16 +245,20 @@ class TimesheetRepository extends EntityRepository /** * @param User $user - * @return Query + * @return Timesheet[]|null */ - public function getActiveEntry(User $user = null) + public function getActiveEntries(User $user = null) { $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('t') + $qb->select('t', 'a', 'p', 'c') ->from('TimesheetBundle:Timesheet', 't') + ->join('t.activity', 'a') + ->join('a.project', 'p') + ->join('p.customer', 'c') ->where($qb->expr()->gt('t.begin', '0')) - ->andWhere($qb->expr()->isNull('t.end')); + ->andWhere($qb->expr()->isNull('t.end')) + ->orderBy('t.begin', 'DESC'); $params = []; @@ -226,8 +278,9 @@ class TimesheetRepository extends EntityRepository { $qb = $this->getEntityManager()->createQueryBuilder(); - $qb->select('t') + $qb->select('t', 'a') ->from('TimesheetBundle:Timesheet', 't') + ->join('t.activity', 'a') ->orderBy('t.begin', 'DESC'); if (null !== $user) { diff --git a/src/TimesheetBundle/Resources/views/Navbar/active-entries.html.twig b/src/TimesheetBundle/Resources/views/Navbar/active-entries.html.twig new file mode 100644 index 00000000..5d58cb40 --- /dev/null +++ b/src/TimesheetBundle/Resources/views/Navbar/active-entries.html.twig @@ -0,0 +1,31 @@ + + diff --git a/src/TimesheetBundle/Resources/views/Navbar/recent-activities.html.twig b/src/TimesheetBundle/Resources/views/Navbar/recent-activities.html.twig new file mode 100644 index 00000000..a4f172f2 --- /dev/null +++ b/src/TimesheetBundle/Resources/views/Navbar/recent-activities.html.twig @@ -0,0 +1,23 @@ +{% if activities is not empty %} + +{% endif %} diff --git a/src/TimesheetBundle/Resources/views/admin/activity.html.twig b/src/TimesheetBundle/Resources/views/admin/activity.html.twig index c7282345..be4723f1 100644 --- a/src/TimesheetBundle/Resources/views/admin/activity.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/activity.html.twig @@ -6,8 +6,6 @@ {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} - {% if entries.count > 0 %} {{ tables.data_table_header({ 'label.id': 'hidden-xs', diff --git a/src/TimesheetBundle/Resources/views/admin/activity_edit.html.twig b/src/TimesheetBundle/Resources/views/admin/activity_edit.html.twig index d901a9b3..6379878f 100644 --- a/src/TimesheetBundle/Resources/views/admin/activity_edit.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/activity_edit.html.twig @@ -6,7 +6,6 @@ {% block page_subtitle %}{{ 'admin_activity.subtitle'|trans }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} {{ include('default/_form.html.twig', { 'title': activity.name, 'form': form, diff --git a/src/TimesheetBundle/Resources/views/admin/customer.html.twig b/src/TimesheetBundle/Resources/views/admin/customer.html.twig index bd811495..e39af36e 100644 --- a/src/TimesheetBundle/Resources/views/admin/customer.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/customer.html.twig @@ -6,22 +6,7 @@ {% block page_subtitle %}{{ 'admin_customer.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} - - {# - private $vat; - private $contact; - private $street; - private $zipcode; - private $city; - private $country; - private $phone; - private $fax; - private $mobile; - private $mail; - private $homepage; - private $timezone; - #} + {# Available fields: vat; contact; address; country; phone; fax; mobile; mail; homepage; timezone; #} {% if entries.count > 0 %} {{ tables.data_table_header({ @@ -29,6 +14,8 @@ 'label.name': '', 'label.project': '', 'label.comment': 'hidden-xs', + 'label.country': 'hidden-xs', + 'label.currency': 'hidden-xs', 'label.visible': '', 'label.actions': '', }) }} @@ -43,6 +30,8 @@ {% endfor %} {{ entry.comment }} + {{ entry.country|country }} + {{ entry.currency }} {{ entry.currency|currency }} {{ widgets.label_visible(entry.visible) }} {{ widgets.button_group({ diff --git a/src/TimesheetBundle/Resources/views/admin/customer_edit.html.twig b/src/TimesheetBundle/Resources/views/admin/customer_edit.html.twig index 26515337..b0158cf8 100644 --- a/src/TimesheetBundle/Resources/views/admin/customer_edit.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/customer_edit.html.twig @@ -6,7 +6,6 @@ {% block page_subtitle %}{{ 'admin_customer.subtitle'|trans }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} {{ include('default/_form.html.twig', { 'title': customer.name, 'form': form, diff --git a/src/TimesheetBundle/Resources/views/admin/project.html.twig b/src/TimesheetBundle/Resources/views/admin/project.html.twig index 393eee58..25fd3088 100644 --- a/src/TimesheetBundle/Resources/views/admin/project.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/project.html.twig @@ -6,8 +6,6 @@ {% block page_subtitle %}{{ 'admin_project.subtitle'|trans }} {{ 'subtitle.amount'|trans({'%count%': entries.count}) }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} - {% if entries.count > 0 %} {{ tables.data_table_header({ 'label.id': 'hidden-xs', @@ -29,7 +27,7 @@ {{ entry.comment }} {{ widgets.badge_counter(entry.activities.count) }} - {{ entry.budget|money(entry.currency) }} + {{ entry.budget|money(entry.customer.currency) }} {{ widgets.label_visible(entry.visible) }} {{ widgets.button_group({ diff --git a/src/TimesheetBundle/Resources/views/admin/project_edit.html.twig b/src/TimesheetBundle/Resources/views/admin/project_edit.html.twig index eaf6c7d3..73df9a54 100644 --- a/src/TimesheetBundle/Resources/views/admin/project_edit.html.twig +++ b/src/TimesheetBundle/Resources/views/admin/project_edit.html.twig @@ -6,7 +6,6 @@ {% block page_subtitle %}{{ 'admin_project.subtitle'|trans }}{% endblock %} {% block main %} - {{ include('default/_flash_messages.html.twig') }} {{ include('default/_form.html.twig', { 'title': project.name, 'form': form, diff --git a/src/TimesheetBundle/Resources/views/timesheet/edit.html.twig b/src/TimesheetBundle/Resources/views/timesheet/edit.html.twig new file mode 100644 index 00000000..923fb4d6 --- /dev/null +++ b/src/TimesheetBundle/Resources/views/timesheet/edit.html.twig @@ -0,0 +1,14 @@ +{% extends 'base.html.twig' %} +{% import "macros/widgets.html.twig" as widgets %} +{% import "macros/datatables.html.twig" as tables %} + +{% block page_title %}{{ 'timesheet.title'|trans }}{% endblock %} +{% block page_subtitle %}{{ 'timesheet.subtitle'|trans }}{% endblock %} + +{% block main %} + {{ include('default/_form.html.twig', { + 'title': 'timesheet.edit'|trans, + 'form': form, + 'back': path('timesheet') + }) }} +{% endblock %} diff --git a/src/TimesheetBundle/Resources/views/timesheet/index.html.twig b/src/TimesheetBundle/Resources/views/timesheet/index.html.twig index 903b8828..87523b8e 100644 --- a/src/TimesheetBundle/Resources/views/timesheet/index.html.twig +++ b/src/TimesheetBundle/Resources/views/timesheet/index.html.twig @@ -13,6 +13,7 @@ 'label.endtime': '', 'label.duration': 'hidden-xs', 'label.rate': 'hidden-xs', + 'label.activity': 'hidden-xs hidden-sm', 'label.description': 'hidden-xs hidden-sm', 'label.actions': '', }) }} @@ -27,12 +28,17 @@ {{ entry.rate|money }} {% else %} ‐ - ‐ + {{ entry.duration|duration }} ‐ {% endif %} + {{ entry.activity.name }} {{ entry.description }} - {{ widgets.button_group({'repeat': '#', 'edit': '#', 'trash': '#'}) }} + {% if entry.end %} + {{ widgets.button_group({'repeat': '#', 'edit': path('timesheet_edit', {'id' : entry.id, 'page': page}), 'trash': '#'}) }} + {% else %} + {{ widgets.button_group({'stop': path('timesheet_stop', {'id' : entry.id}), 'edit': path('timesheet_edit', {'id' : entry.id, 'page': page}), 'trash': '#'}) }} + {% endif %} {% endfor %} diff --git a/web/app.php b/web/app.php index 2056915c..ffd5b0a5 100644 --- a/web/app.php +++ b/web/app.php @@ -28,7 +28,6 @@ include_once __DIR__.'/../var/bootstrap.php.cache'; // $apcLoader->register(true); $kernel = new AppKernel('prod', false); -$kernel->loadClassCache(); // When using the HTTP Cache to improve application performance, the application // kernel is wrapped by the AppCache class to activate the built-in reverse proxy. diff --git a/web/app_dev.php b/web/app_dev.php index e7c7019d..b6393f0d 100644 --- a/web/app_dev.php +++ b/web/app_dev.php @@ -32,7 +32,6 @@ $loader = require __DIR__.'/../app/autoload.php'; Debug::enable(); $kernel = new AppKernel('dev', true); -$kernel->loadClassCache(); $request = Request::createFromGlobals(); $response = $kernel->handle($request); $response->send(); diff --git a/web/css/kimai.css b/web/css/kimai.css index 9d0f0478..2bc92e17 100644 --- a/web/css/kimai.css +++ b/web/css/kimai.css @@ -1,28 +1,57 @@ -#ticktac img { - width: 30px; +/* ================================ NAVBAR ================================ */ + +.navbar-nav>li>a.ddt-large { + padding-top: 12px; + padding-bottom: 9px; } +li.messages-menu ul.menu li:hover .pull-left i { + color: #dd4b39; + +} +/* +.ticktac:hover i.running{ + color: #4ff131; +} + +li.open .ticktac i.running{ + color: #4ff131; +} + +.ticktac i.stopped{ + color: #fff; +} + +.ticktac:hover i.stopped{ + color: #4ff131; +} .user-panel>.info { left: 40px; padding-top: 0px; } -/* -#ticktac img { - width: 100%; -} +*/ + + +/* ================================ SIDEBAR ================================ */ + +/* Used for the Sidebar UserPanel (which is deactivated right now) */ .user-panel>.info { left: 80px; } -*/ + +/* ================================ FOOTER ================================ */ + footer.main-footer { padding: 5px; font-size: 80%; } + +/* ================================ DATA-TABLES ================================ */ + /* - TODO - "display:hidden" regel für dataTable - spalten - mobile ansicht - "display:hidden" regel für dataTable - header icon - mobile ansicht + TODO "display:hidden" regel für dataTable - spalten - mobile ansicht + TODO "display:hidden" regel für dataTable - header icon - mobile ansicht */ diff --git a/web/js/kimai.js b/web/js/kimai.js index 25baedd2..a4268ffc 100644 --- a/web/js/kimai.js +++ b/web/js/kimai.js @@ -20,11 +20,15 @@ $(document).ready(function() { publish: function() { pubSub.trigger.apply(pubSub, arguments); }, - ticktac: function(selector) { - $(selector + ' img').hover(function () { - $(this).attr('src', $.fn.kimai.settings.imagePath + '/buzzer-on-hover.png'); + pauseRecord: function(selector) { + $(selector + ' .pull-left i').hover(function () { + var link = $(this).parents('a'); + link.attr('href', link.attr('href').replace('/stop', '/pause')); + $(this).removeClass('fa-stop-circle').addClass('fa-pause-circle').addClass('text-orange'); },function () { - $(this).attr('src', $.fn.kimai.settings.imagePath + '/buzzer-on.png'); + var link = $(this).parents('a'); + link.attr('href', link.attr('href').replace('/pause', '/stop')); + $(this).removeClass('fa-pause-circle').removeClass('text-orange').addClass('fa-stop-circle'); }); } };