diff --git a/README.md b/README.md index 0bbb24f7..392b9936 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ Kimai v2 - the reloaded open source Time-Tracking application. This is (or will be in the future, currently a lot of features are still missing) the reloaded version of the open source time-tracking application [Kimai](http://www.kimai.org). -It is based on the following PHP components: +It is based on a lot of great PHP components. Special thanks to: - [Symfony Framework 4](https://github.com/symfony/symfony) -- [AdminThemeBundle](https://github.com/avanzu/AdminThemeBundle/) (based on [AdminLTE](https://github.com/almasaeed2010/AdminLTE/)) - [Doctrine](https://github.com/doctrine/) +- [AdminThemeBundle](https://github.com/avanzu/AdminThemeBundle/) (based on [AdminLTE](https://github.com/almasaeed2010/AdminLTE/)) ## Requirements diff --git a/composer.lock b/composer.lock index 77ee5af5..b23ad57f 100644 --- a/composer.lock +++ b/composer.lock @@ -52,12 +52,12 @@ "source": { "type": "git", "url": "https://github.com/kevinpapst/AdminThemeBundle.git", - "reference": "d31084eff7888d76f7976700f9ac878f40c3bacd" + "reference": "d0d849ac59889ad6f4408fee84e23b09d7d65d36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/kevinpapst/AdminThemeBundle/zipball/d31084eff7888d76f7976700f9ac878f40c3bacd", - "reference": "d31084eff7888d76f7976700f9ac878f40c3bacd", + "url": "https://api.github.com/repos/kevinpapst/AdminThemeBundle/zipball/d0d849ac59889ad6f4408fee84e23b09d7d65d36", + "reference": "d0d849ac59889ad6f4408fee84e23b09d7d65d36", "shasum": "" }, "require": { @@ -111,7 +111,7 @@ "support": { "source": "https://github.com/kevinpapst/AdminThemeBundle/tree/kevinpapst" }, - "time": "2018-01-17T12:31:37+00:00" + "time": "2018-01-21T19:07:18+00:00" }, { "name": "beberlei/DoctrineExtensions", diff --git a/config/packages/kimai.yml b/config/packages/kimai.yaml similarity index 55% rename from config/packages/kimai.yml rename to config/packages/kimai.yaml index ebdec234..d0004ba3 100644 --- a/config/packages/kimai.yml +++ b/config/packages/kimai.yaml @@ -1,13 +1,22 @@ +kimai: + timesheet: + rounding: 15 + invoice: + renderer: + default: 'App\Controller\InvoiceController::invoiceAction' + calculator: + default: 'App\Invoice\DefaultCalculator' + number_generator: + default: 'App\Invoice\DateNumberGenerator' + #random: 'App\Invoice\RandomNumberGenerator' + twig: -# form_themes: -# - "bootstrap_3_layout.html.twig" -# - "form/fields.html.twig" globals: kimai_context: 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 - control_sidebar: + control_sidebar: # all tabs in the control sidebar settings: icon: gears controller: 'App\Controller\SidebarController::settingsAction' diff --git a/config/services.yaml b/config/services.yaml index bafd62a7..dac7cdf6 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -12,6 +12,8 @@ services: public: false # Allows optimizing the container by removing unused services; this also means # fetching services directly from the container via $container->get() won't work. # The best practice is to be explicit about your dependencies anyway. + bind: + $projectDirectory: "%kernel.project_dir%" # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -25,13 +27,14 @@ services: resource: '../src/Controller' tags: ['controller.service_arguments'] - # add more service definitions when explicit configuration is needed - # please note that last definitions always *replace* previous ones - # ================================================================================ # APPLICATION CORE # ================================================================================ + App\Invoice\ServiceInvoice: + class: App\Invoice\ServiceInvoice + arguments: ['%kimai.invoice%', '@service_container'] + # a route listener, that injects the locale through a URL directory App\EventSubscriber\RedirectToLocaleSubscriber: class: App\EventSubscriber\RedirectToLocaleSubscriber @@ -44,34 +47,25 @@ services: # ================================================================================ # service that prefixes every database table - app.tableprefix_subscriber: + App\Doctrine\TablePrefixSubscriber: class: App\Doctrine\TablePrefixSubscriber arguments: ["%env(resolve:DATABASE_PREFIX)%"] tags: - { name: doctrine.event_subscriber } # updates timesheet records, to make sure they are calculated and can't be manipulated by the users - app.database_listener.timesheet: + App\Doctrine\TimesheetSubscriber: class: App\Doctrine\TimesheetSubscriber tags: - { name: doctrine.event_listener, event: prePersist, lazy: true } - { name: doctrine.event_listener, event: preUpdate, lazy: true } - # Uncomment the following lines to define a service for the Timesheet Doctrine repository. - # It's not mandatory to create these services, but if you use repositories a lot, - # these services can simplify your code: + # Uncomment the following lines to define a service for the Timesheet Doctrine repository # - # app.post_repository: + # app.timesheet_repository: # class: Doctrine\ORM\EntityRepository # factory: ['@doctrine.orm.entity_manager', getRepository] # arguments: [App\Entity\Timesheet] - # - # // traditional code inside a controller - # $entityManager = $this->getDoctrine()->getManager(); - # $posts = $entityManager->getRepository('KimaiBundle:User')->findAll(); - # - # // same code using repository services - # $posts = $this->get('app.user_repository')->findAll(); # ================================================================================ # FORMS @@ -84,29 +78,6 @@ services: tags: - { name: form.type } - # ================================================================================ - # CONSOLE COMMANDS - # ================================================================================ - - App\Command\RunCodeSnifferCommand: - arguments: - $projectDirectory: "%kernel.project_dir%" - - App\Command\RunUnitTestsCommand: - class: App\Command\RunUnitTestsCommand - arguments: - $projectDirectory: "%kernel.project_dir%" - - App\Command\RunIntegrationTestsCommand: - class: App\Command\RunIntegrationTestsCommand - arguments: - $projectDirectory: "%kernel.project_dir%" - - App\Command\InstallCommand: - class: App\Command\InstallCommand - arguments: - $projectDirectory: "%kernel.project_dir%" - # ================================================================================ # THEME # ================================================================================ @@ -128,9 +99,3 @@ services: # class: "%avanzu_admin_theme.navbar_msg_listener.class%" # tags: # - { name: kernel.event_listener, event: theme.messages, method: onListMessages } - # - # avanzu_admin_theme.setup_menu_listener: - # class: "%avanzu_admin_theme.setup_menu_listener.class%" - # tags: - # - { name: kernel.event_listener, event: theme.sidebar_setup_menu, method: onSetupMenu } - # - { name: kernel.event_listener, event: theme.breadcrumb, method: onSetupMenu } diff --git a/public/css/kimai.css b/public/css/kimai.css index a4e26400..2e8d52a9 100644 --- a/public/css/kimai.css +++ b/public/css/kimai.css @@ -1,3 +1,8 @@ + +.error-page { + margin-bottom: 50px; +} + /* ================================ NAVBAR ================================ */ .navbar-nav>li>a.ddt-large { @@ -42,6 +47,7 @@ li.open .ticktac i.running{ .control-sidebar select { color: #000; } + /* ================================ TOOLBAR ================================ */ .data_table .box-header>.box-tools { @@ -61,7 +67,7 @@ li.open .ticktac i.running{ display: inline; } -.box-header .tools-left { +.tools-left { margin-right: 10px; } @@ -84,6 +90,11 @@ footer.main-footer { .login-page form label { padding-left: 5px; } +/* ================================ PRINT ================================ */ + +@media print{ + .sf-toolbar, .control-sidebar {display: none !important;} +} /* ================================ DATA-TABLES ================================ */ diff --git a/public/js/toolbar.js b/public/js/toolbar.js index 549bc3a6..f6413644 100644 --- a/public/js/toolbar.js +++ b/public/js/toolbar.js @@ -1,5 +1,9 @@ $(document).ready(function () { + $('.toolbar form input').change(function (event) { + $('.toolbar form').submit(); + }); + $('.toolbar form select').change(function (event) { switch (event.target.id) { case 'customer': diff --git a/src/Controller/Admin/ActivityController.php b/src/Controller/Admin/ActivityController.php index 3b436315..4345538a 100644 --- a/src/Controller/Admin/ActivityController.php +++ b/src/Controller/Admin/ActivityController.php @@ -19,8 +19,6 @@ 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 App\Entity\Customer; -use App\Entity\Project; use App\Form\ActivityEditForm; use App\Form\Toolbar\ActivityToolbarForm; use App\Repository\Query\ActivityQuery; @@ -45,47 +43,6 @@ class ActivityController extends AbstractController return $this->getDoctrine()->getRepository(Activity::class); } - /** - * @param Request $request - * @return ActivityQuery - */ - protected function getQueryForRequest(Request $request) - { - $visibility = $request->get('visibility', ActivityQuery::SHOW_VISIBLE); - if (strlen($visibility) == 0 || (int)$visibility != $visibility) { - $visibility = ActivityQuery::SHOW_BOTH; - } - $pageSize = (int) $request->get('pageSize'); - $customer = $request->get('customer'); - $customer = !empty(trim($customer)) ? trim($customer) : null; - $project = $request->get('project'); - $project = !empty(trim($project)) ? trim($project) : null; - - if ($project !== null) { - $repo = $this->getDoctrine()->getRepository(Project::class); - $project = $repo->getById($project); - if ($project !== null) { - $customer = $project->getCustomer(); - } else { - $customer = null; - } - } elseif ($customer !== null) { - $repo = $this->getDoctrine()->getRepository(Customer::class); - $customer = $repo->getById($customer); - } - - $query = new ActivityQuery(); - $query - ->setPageSize($pageSize) - ->setVisibility($visibility) - ->setCustomer($customer) - ->setProject($project) - ->setExclusiveVisibility(true) - ; - - return $query ; - } - /** * @Route("/", defaults={"page": 1}, name="admin_activity") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_activity_paginated") @@ -94,22 +51,33 @@ class ActivityController extends AbstractController */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); + $query = new ActivityQuery(); + $query->setExclusiveVisibility(true); $query->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, - 'toolbarForm' => $this->getToolbarForm($query)->createView(), + 'toolbarForm' => $form->createView(), ]); } /** * @Route("/create", name="admin_activity_create") * @Method({"GET", "POST"}) + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response */ public function createAction(Request $request) { @@ -120,6 +88,10 @@ class ActivityController extends AbstractController * @Route("/{id}/edit", name="admin_activity_edit") * @Method({"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) { @@ -204,16 +176,12 @@ class ActivityController extends AbstractController */ protected function getToolbarForm(ActivityQuery $query) { - return $this->createForm( - ActivityToolbarForm::class, - $query, - [ - 'action' => $this->generateUrl('admin_activity_paginated', [ - 'page' => $query->getPage(), - ]), - 'method' => 'GET', - ] - ); + return $this->createForm(ActivityToolbarForm::class, $query, [ + 'action' => $this->generateUrl('admin_activity', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); } /** @@ -228,13 +196,9 @@ class ActivityController extends AbstractController $url = $this->generateUrl('admin_activity_edit', ['id' => $activity->getId()]); } - return $this->createForm( - ActivityEditForm::class, - $activity, - [ - 'action' => $url, - 'method' => 'POST' - ] - ); + return $this->createForm(ActivityEditForm::class, $activity, [ + 'action' => $url, + 'method' => 'POST' + ]); } } diff --git a/src/Controller/Admin/CustomerController.php b/src/Controller/Admin/CustomerController.php index 7cb78f5f..6e1fdb19 100644 --- a/src/Controller/Admin/CustomerController.php +++ b/src/Controller/Admin/CustomerController.php @@ -18,7 +18,6 @@ use App\Entity\Customer; 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 App\Form\CustomerEditForm; use App\Form\Toolbar\CustomerToolbarForm; use App\Repository\Query\CustomerQuery; @@ -43,44 +42,30 @@ class CustomerController extends AbstractController return $this->getDoctrine()->getRepository(Customer::class); } - /** - * @param Request $request - * @return CustomerQuery - */ - protected function getQueryForRequest(Request $request) - { - $visibility = $request->get('visibility', CustomerQuery::SHOW_VISIBLE); - if (strlen($visibility) == 0 || (int)$visibility != $visibility) { - $visibility = CustomerQuery::SHOW_BOTH; - } - $pageSize = (int) $request->get('pageSize'); - - $query = new CustomerQuery(); - $query - ->setPageSize($pageSize) - ->setVisibility($visibility); - - return $query ; - } - /** * @Route("/", defaults={"page": 1}, name="admin_customer") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_customer_paginated") * @Method("GET") - * @Cache(smaxage="10") */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); + $query = new CustomerQuery(); $query->setPage($page); + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var CustomerQuery $query */ + $query = $form->getData(); + } + /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); return $this->render('admin/customer.html.twig', [ 'entries' => $entries, 'query' => $query, - 'toolbarForm' => $this->getToolbarForm($query)->createView(), + 'toolbarForm' => $form->createView(), ]); } diff --git a/src/Controller/Admin/ProjectController.php b/src/Controller/Admin/ProjectController.php index 0830246b..295806e4 100644 --- a/src/Controller/Admin/ProjectController.php +++ b/src/Controller/Admin/ProjectController.php @@ -44,36 +44,6 @@ class ProjectController extends AbstractController return $this->getDoctrine()->getRepository(Project::class); } - /** - * @param Request $request - * @return ProjectQuery - */ - protected function getQueryForRequest(Request $request) - { - $visibility = $request->get('visibility', ProjectQuery::SHOW_VISIBLE); - if (strlen($visibility) == 0 || (int)$visibility != $visibility) { - $visibility = ProjectQuery::SHOW_BOTH; - } - $pageSize = (int) $request->get('pageSize'); - $customer = $request->get('customer'); - $customer = !empty(trim($customer)) ? trim($customer) : null; - - if ($customer !== null) { - $repo = $this->getDoctrine()->getRepository(Customer::class); - $customer = $repo->getById($customer); - } - - $query = new ProjectQuery(); - $query - ->setPageSize($pageSize) - ->setVisibility($visibility) - ->setCustomer($customer) - ->setExclusiveVisibility(true) - ; - - return $query ; - } - /** * @Route("/", defaults={"page": 1}, name="admin_project") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_project_paginated") @@ -82,16 +52,24 @@ class ProjectController extends AbstractController */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); + $query = new ProjectQuery(); + $query->setExclusiveVisibility(true); $query->setPage($page); + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var ProjectQuery $query */ + $query = $form->getData(); + } + /* @var $entries Pagerfanta */ $entries = $this->getDoctrine()->getRepository(Project::class)->findByQuery($query); return $this->render('admin/project.html.twig', [ 'entries' => $entries, 'query' => $query, - 'toolbarForm' => $this->getToolbarForm($query)->createView(), + 'toolbarForm' => $form->createView(), ]); } diff --git a/src/Controller/Admin/TimesheetController.php b/src/Controller/Admin/TimesheetController.php index 38ce0f0f..c6fea840 100644 --- a/src/Controller/Admin/TimesheetController.php +++ b/src/Controller/Admin/TimesheetController.php @@ -12,6 +12,8 @@ namespace App\Controller\Admin; use App\Controller\AbstractController; +use App\Form\Toolbar\TimesheetAdminToolbarForm; +use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; use Symfony\Component\HttpFoundation\Request; use App\Controller\TimesheetControllerTrait; @@ -48,9 +50,16 @@ class TimesheetController extends AbstractController */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); + $query = new TimesheetQuery(); $query->setPage($page); + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var TimesheetQuery $query */ + $query = $form->getData(); + } + /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); @@ -58,7 +67,7 @@ class TimesheetController extends AbstractController 'entries' => $entries, 'page' => $page, 'query' => $query, - 'toolbarForm' => $this->getToolbarForm($query, 'admin_timesheet')->createView(), + 'toolbarForm' => $form->createView(), ]); } @@ -133,15 +142,11 @@ class TimesheetController extends AbstractController */ protected function getCreateForm(Timesheet $entry) { - return $this->createForm( - TimesheetAdminForm::class, - $entry, - [ - 'action' => $this->generateUrl('admin_timesheet_create'), - 'method' => 'POST', - 'currency' => Customer::DEFAULT_CURRENCY, - ] - ); + return $this->createForm(TimesheetAdminForm::class, $entry, [ + 'action' => $this->generateUrl('admin_timesheet_create'), + 'method' => 'POST', + 'currency' => Customer::DEFAULT_CURRENCY, + ]); } /** @@ -151,17 +156,27 @@ class TimesheetController extends AbstractController */ protected function getEditForm(Timesheet $entry, $page) { - return $this->createForm( - TimesheetAdminForm::class, - $entry, - [ - 'action' => $this->generateUrl('admin_timesheet_edit', [ - 'id' => $entry->getId(), - 'page' => $page - ]), - 'method' => 'POST', - 'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(), - ] - ); + return $this->createForm(TimesheetAdminForm::class, $entry, [ + 'action' => $this->generateUrl('admin_timesheet_edit', [ + 'id' => $entry->getId(), + 'page' => $page + ]), + 'method' => 'POST', + 'currency' => $entry->getActivity()->getProject()->getCustomer()->getCurrency(), + ]); + } + + /** + * @param TimesheetQuery $query + * @return \Symfony\Component\Form\FormInterface + */ + protected function getToolbarForm(TimesheetQuery $query) + { + return $this->createForm(TimesheetAdminToolbarForm::class, $query, [ + 'action' => $this->generateUrl('admin_timesheet', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); } } diff --git a/src/Controller/Admin/UserController.php b/src/Controller/Admin/UserController.php index 52e1bb1a..59346857 100644 --- a/src/Controller/Admin/UserController.php +++ b/src/Controller/Admin/UserController.php @@ -20,7 +20,6 @@ use Pagerfanta\Pagerfanta; 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; /** @@ -35,29 +34,6 @@ use Symfony\Component\HttpFoundation\Request; class UserController extends AbstractController { - /** - * @param Request $request - * @return UserQuery - */ - protected function getQueryForRequest(Request $request) - { - $visibility = $request->get('visibility', UserQuery::SHOW_VISIBLE); - if (strlen($visibility) == 0 || (int)$visibility != $visibility) { - $visibility = UserQuery::SHOW_BOTH; - } - $pageSize = (int) $request->get('pageSize'); - $userRole = $request->get('role'); - - $query = new UserQuery(); - $query - ->setPageSize($pageSize) - ->setVisibility($visibility) - ->setRole($userRole) - ; - - return $query; - } - /** * @Route("/", defaults={"page": 1}, name="admin_user") * @Route("/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_user_paginated") @@ -65,16 +41,23 @@ class UserController extends AbstractController */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); + $query = new UserQuery(); $query->setPage($page); + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var UserQuery $query */ + $query = $form->getData(); + } + /* @var $entries Pagerfanta */ $entries = $this->getDoctrine()->getRepository(User::class)->findByQuery($query); return $this->render('admin/user.html.twig', [ 'entries' => $entries, 'query' => $query, - 'toolbarForm' => $this->getToolbarForm($query)->createView(), + 'toolbarForm' => $form->createView(), ]); } @@ -120,16 +103,12 @@ class UserController extends AbstractController */ protected function getToolbarForm(UserQuery $query) { - return $this->createForm( - UserToolbarForm::class, - $query, - [ - 'action' => $this->generateUrl('admin_user_paginated', [ - 'page' => $query->getPage(), - ]), - 'method' => 'GET', - ] - ); + return $this->createForm(UserToolbarForm::class, $query, [ + 'action' => $this->generateUrl('admin_user_paginated', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); } /** @@ -138,13 +117,9 @@ class UserController extends AbstractController */ private function createEditForm(User $user) { - return $this->createForm( - UserCreateType::class, - $user, - [ - 'action' => $this->generateUrl('admin_user_create'), - 'method' => 'POST' - ] - ); + return $this->createForm(UserCreateType::class, $user, [ + 'action' => $this->generateUrl('admin_user_create'), + 'method' => 'POST' + ]); } } diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php new file mode 100644 index 00000000..8cc17ddf --- /dev/null +++ b/src/Controller/InvoiceController.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Controller; + +use App\Entity\InvoiceTemplate; +use App\Entity\Timesheet; +use App\Form\InvoiceTemplateForm; +use App\Form\Toolbar\InvoiceToolbarForm; +use App\Invoice\ServiceInvoice; +use App\Model\InvoiceModel; +use App\Repository\Query\BaseQuery; +use App\Repository\Query\InvoiceQuery; +use App\Repository\Query\TimesheetQuery; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; +use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; +use Symfony\Component\HttpFoundation\Request; + +/** + * Controller used to manage invoices. + * + * @Route("/invoice") + * @Security("has_role('ROLE_TEAMLEAD')") + * + * @author Kevin Papst + */ +class InvoiceController extends AbstractController +{ + + /** + * @var ServiceInvoice + */ + protected $service; + + /** + * InvoiceController constructor. + * @param ServiceInvoice $service + */ + public function __construct(ServiceInvoice $service) + { + $this->service = $service; + } + + /** + * @return InvoiceQuery + * @throws \Exception + */ + protected function getDefaultQuery() + { + $begin = new \DateTime('first day of this month'); + $end = new \DateTime('last day of this month'); + + $query = new InvoiceQuery(); + $query->setBegin($begin); + $query->setEnd($end); + $query->setUser($this->getUser()); + $query->setState(InvoiceQuery::STATE_STOPPED); + + return $query; + } + + /** + * @return \App\Repository\InvoiceTemplateRepository + */ + protected function getRepository() + { + return $this->getDoctrine()->getRepository(InvoiceTemplate::class); + } + + /** + * @Route("/", name="invoice") + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response + * @throws \Exception + */ + public function indexAction(Request $request) + { + if (!$this->getRepository()->hasTemplate()) { + return $this->redirectToRoute('admin_invoice_template_create'); + } + + $entries = []; + + $query = $this->getDefaultQuery(); + $form = $this->getToolbarForm($query); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var InvoiceQuery $query */ + $query = $form->getData(); + $query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER); + + if ($query->getCustomer() !== null) { + $query->getBegin()->setTime(0, 0, 0); + $query->getEnd()->setTime(23, 59, 59); + + $queryBuilder = $this->getDoctrine()->getRepository(Timesheet::class)->findByQuery($query); + $entries = $queryBuilder->getQuery()->getResult(); + } + } + + $model = new InvoiceModel(); + $model->setQuery($query); + $model->setEntries($entries); + $model->setCustomer($query->getCustomer()); + + $action = null; + if ($query->getTemplate() !== null) { + $generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator()); + if ($generator === null) { + throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator()); + } + + $calculator = $this->service->getCalculatorByName($query->getTemplate()->getCalculator()); + if ($calculator === null) { + throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator()); + } + + $model->setTemplate($query->getTemplate()); + $model->setCalculator($calculator); + $model->setNumberGenerator($generator); + $action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer()); + } + + return $this->render('invoice/index.html.twig', [ + 'model' => $model, + 'action' => $action, + 'form' => $form->createView(), + ]); + } + + /** + * @Route("/template", defaults={"page": 1}, name="admin_invoice_template") + * @Route("/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated") + * @Method({"GET", "POST"}) + * + * TODO permission + * + * @param $page + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response + */ + public function listTemplateAction($page, Request $request) + { + $templates = $this->getRepository()->findByQuery(new BaseQuery()); + return $this->render('invoice/templates.html.twig', [ + 'entries' => $templates, + 'page' => $page, + ]); + } + + /** + * @Route("/{id}/edit", name="admin_invoice_template_edit") + * @Method({"GET", "POST"}) + * + * TODO permission + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response + * @throws \Exception + */ + public function editTemplateAction(InvoiceTemplate $template, Request $request) + { + return $this->renderTemplateForm($template, $request); + } + + /** + * @Route("/create", name="admin_invoice_template_create") + * @Method({"GET", "POST"}) + * + * TODO permission + * + * @param Request $request + * @return \Symfony\Component\HttpFoundation\Response + * @throws \Exception + */ + public function createTemplateAction(Request $request) + { + if (!$this->getRepository()->hasTemplate()) { + $this->flashWarning('invoice.first_template'); + } + return $this->renderTemplateForm(new InvoiceTemplate(), $request); + } + + /** + * @param InvoiceTemplate $template + * @param Request $request + * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response + */ + protected function renderTemplateForm(InvoiceTemplate $template, Request $request) + { + $editForm = $this->createEditForm($template); + + $editForm->handleRequest($request); + + if ($editForm->isSubmitted() && $editForm->isValid()) { + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($template); + $entityManager->flush(); + + $this->flashSuccess('action.updated_successfully'); + + return $this->redirectToRoute('admin_invoice_template'); + } + + return $this->render('invoice/template_edit.html.twig', [ + 'template' => $template, + 'form' => $editForm->createView() + ]); + } + + /** + * @param InvoiceModel $model + * @return \Symfony\Component\HttpFoundation\Response + */ + public function invoiceAction(InvoiceModel $model) + { + return $this->render('invoice/print.html.twig', [ + 'model' => $model, + ]); + } + + /** + * @param InvoiceQuery $query + * @return \Symfony\Component\Form\FormInterface + */ + protected function getToolbarForm(InvoiceQuery $query) + { + return $this->createForm(InvoiceToolbarForm::class, $query, [ + 'action' => $this->generateUrl('invoice', []), + 'method' => 'GET', + ]); + } + + /** + * @param InvoiceTemplate $template + * @return \Symfony\Component\Form\FormInterface + */ + private function createEditForm(InvoiceTemplate $template) + { + if ($template->getId() === null) { + $url = $this->generateUrl('admin_invoice_template_create'); + } else { + $url = $this->generateUrl('admin_invoice_template_edit', ['id' => $template->getId()]); + } + + return $this->createForm(InvoiceTemplateForm::class, $template, [ + 'action' => $url, + 'method' => 'POST' + ]); + } +} diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index 4f19a45c..1376f633 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -11,7 +11,8 @@ namespace App\Controller; -use App\Controller\AbstractController; +use App\Form\Toolbar\TimesheetToolbarForm; +use App\Repository\Query\TimesheetQuery; use Pagerfanta\Pagerfanta; use App\Entity\Activity; use App\Entity\Customer; @@ -43,10 +44,18 @@ class TimesheetController extends AbstractController */ public function indexAction($page, Request $request) { - $query = $this->getQueryForRequest($request); - $query->setUser($this->getUser()); + $query = new TimesheetQuery(); $query->setPage($page); + $form = $this->getToolbarForm($query); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var TimesheetQuery $query */ + $query = $form->getData(); + } + + $query->setUser($this->getUser()); + /* @var $entries Pagerfanta */ $entries = $this->getRepository()->findByQuery($query); @@ -54,7 +63,7 @@ class TimesheetController extends AbstractController 'entries' => $entries, 'page' => $page, 'query' => $query, - 'toolbarForm' => $this->getToolbarForm($query)->createView(), + 'toolbarForm' => $form->createView(), ]); } @@ -201,4 +210,18 @@ class TimesheetController extends AbstractController ] ); } + + /** + * @param TimesheetQuery $query + * @return \Symfony\Component\Form\FormInterface + */ + protected function getToolbarForm(TimesheetQuery $query) + { + return $this->createForm(TimesheetToolbarForm::class, $query, [ + 'action' => $this->generateUrl('timesheet', [ + 'page' => $query->getPage(), + ]), + 'method' => 'GET', + ]); + } } diff --git a/src/Controller/TimesheetControllerTrait.php b/src/Controller/TimesheetControllerTrait.php index f47ee069..5e5ae3ec 100644 --- a/src/Controller/TimesheetControllerTrait.php +++ b/src/Controller/TimesheetControllerTrait.php @@ -11,13 +11,8 @@ namespace App\Controller; -use App\Entity\Activity; -use App\Entity\Customer; -use App\Entity\Project; use App\Entity\Timesheet; use Symfony\Component\HttpFoundation\Request; -use App\Form\Toolbar\TimesheetToolbarForm; -use App\Repository\Query\TimesheetQuery; use App\Repository\TimesheetRepository; /** @@ -35,77 +30,6 @@ trait TimesheetControllerTrait return $this->getDoctrine()->getRepository(Timesheet::class); } - /** - * @param Request $request - * @return TimesheetQuery - */ - protected function getQueryForRequest(Request $request) - { - $activity = $request->get('activity'); - $activity = !empty(trim($activity)) ? trim($activity) : null; - $project = $request->get('project'); - $project = !empty(trim($project)) ? trim($project) : null; - $customer = $request->get('customer'); - $customer = !empty(trim($customer)) ? trim($customer) : null; - $state = $request->get('state'); - $state = !empty(trim($state)) ? trim($state) : null; - $pageSize = (int) $request->get('pageSize'); - - if ($activity !== null) { - $repo = $this->getDoctrine()->getRepository(Activity::class); - $activity = $repo->getById($activity); - if ($activity !== null) { - $project = $activity->getProject(); - if ($project !== null) { - $customer = $project->getCustomer(); - } - } else { - $customer = null; - $project = null; - } - } elseif ($project !== null) { - $repo = $this->getDoctrine()->getRepository(Project::class); - $project = $repo->getById($project); - if ($project !== null) { - $customer = $project->getCustomer(); - } else { - $customer = null; - } - } elseif ($customer !== null) { - $repo = $this->getDoctrine()->getRepository(Customer::class); - $customer = $repo->getById($customer); - } - - $query = new TimesheetQuery(); - $query - ->setActivity($activity) - ->setProject($project) - ->setCustomer($customer) - ->setPageSize($pageSize) - ->setState($state); - - return $query ; - } - - /** - * @param TimesheetQuery $query - * @param string $route - * @return mixed - */ - protected function getToolbarForm(TimesheetQuery $query, $route = 'timesheet') - { - return $this->createForm( - TimesheetToolbarForm::class, - $query, - [ - 'action' => $this->generateUrl($route, [ - 'page' => $query->getPage(), - ]), - 'method' => 'GET', - ] - ); - } - /** * @param Timesheet $entry * @param string $route diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index d1690dde..a555c271 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -12,17 +12,23 @@ namespace App\DataFixtures; use App\Entity\User; +use App\Entity\UserPreference; use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\Persistence\ObjectManager; use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface; /** - * Sample data to load in the database when running tests or for development + * Defines the sample data to load in the database when running the unit and + * functional tests or while development. + * + * Execute this command to load the data: + * $ php bin/console doctrine:fixtures:load * * @author Kevin Papst */ class AppFixtures extends Fixture { + use FixturesTrait; const DEFAULT_PASSWORD = 'kitten'; @@ -31,6 +37,10 @@ class AppFixtures extends Fixture */ private $encoder; + /** + * AppFixtures constructor. + * @param UserPasswordEncoderInterface $encoder + */ public function __construct(UserPasswordEncoderInterface $encoder) { $this->encoder = $encoder; @@ -44,117 +54,53 @@ class AppFixtures extends Fixture $this->loadUsers($manager); } + /** + * @param ObjectManager $manager + */ private function loadUsers(ObjectManager $manager) { $passwordEncoder = $this->encoder; - $claraCustomer = new User(); - $claraCustomer - ->setAlias('Clara Haynes') - ->setTitle('CFO') - ->setUsername('clara_customer') - ->setEmail('clara_customer@example.com') - ->setRoles(['ROLE_CUSTOMER']) - ->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y') - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - ; - $manager->persist($claraCustomer); + foreach ($this->getUserDefinition() as $userData) { + $user = new User(); + $user + ->setAlias($userData[0]) + ->setTitle($userData[1]) + ->setUsername($userData[2]) + ->setEmail($userData[3]) + ->setRoles([$userData[4]]) + ->setAvatar($userData[5]) + ->setActive($userData[6]) + ->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD)) + ; - $johnUser = new User(); - $johnUser - ->setAlias('John Doe') - ->setTitle('Developer') - ->setUsername('john_user') - ->setEmail('john_user@example.com') - ->setRoles(['ROLE_USER']) - ->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y') - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - ; - $manager->persist($johnUser); + $preference = new UserPreference(); + $preference->setName(UserPreference::HOURLY_RATE); + $preference->setValue(rand(0, 100)); + $preference->setUser($user); + $user->setPreferences([$preference]); - $deactiveUser = new User(); - $deactiveUser - ->setAlias('Chris Deactive') - ->setTitle('Developer (left company)') - ->setUsername('chris_user') - ->setEmail('chris_user@example.com') - ->setRoles(['ROLE_USER']) - ->setAvatar('https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y') - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - // inactive for testing user login and UI - ->setActive(false) - ; - $manager->persist($deactiveUser); - - $tonyTeamlead = new User(); - $tonyTeamlead - ->setAlias('Tony Maier') - ->setTitle('Head of Development') - ->setUsername('tony_teamlead') - ->setEmail('tony_teamlead@example.com') - ->setRoles(['ROLE_TEAMLEAD']) - ->setAvatar('https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg') - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - ; - $manager->persist($tonyTeamlead); - - $annaAdmin = new User(); - $annaAdmin - ->setAlias('Anna Smith') - ->setTitle('Administrator') - ->setUsername('anna_admin') - ->setEmail('anna_admin@example.com') - ->setRoles(['ROLE_ADMIN']) - // no avatar to test default image! - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - ; - $manager->persist($annaAdmin); - - $susanSuper = new User(); - $susanSuper - // no alias to test the username macros - ->setTitle('Super Administrator') - ->setUsername('susan_super') - ->setEmail('susan_super@example.com') - ->setRoles(['ROLE_SUPER_ADMIN']) - ->setAvatar('/bundles/avanzuadmintheme/img/avatar.png') - ->setPassword($passwordEncoder->encodePassword($claraCustomer, self::DEFAULT_PASSWORD)) - ; - $manager->persist($susanSuper); + $manager->persist($user); + } $manager->flush(); } - protected function getPhrases() + /** + * @return [] + */ + protected function getUserDefinition() { return [ - 'Lorem ipsum dolor sit amet consectetur adipiscing elit', - 'Pellentesque vitae velit ex', - 'Mauris dapibus risus quis suscipit vulputate', - 'Eros diam egestas libero eu vulputate risus', - 'In hac habitasse platea dictumst', - 'Morbi tempus commodo mattis', - 'Ut suscipit posuere justo at vulputate', - 'Ut eleifend mauris et risus ultrices egestas', - 'Aliquam sodales odio id eleifend tristique', - 'Urna nisl sollicitudin id varius orci quam id turpis', - 'Nulla porta lobortis ligula vel egestas', - 'Curabitur aliquam euismod dolor non ornare', - 'Sed varius a risus eget aliquam', - 'Nunc viverra elit ac laoreet suscipit', - 'Pellentesque et sapien pulvinar consectetur', + ['Clara Haynes', 'CFO', 'clara_customer', 'clara_customer@example.com', 'ROLE_CUSTOMER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=monsterid&f=y', true], + ['John Doe', 'Developer', 'john_user', 'john_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', true], + // inactive user to test login + ['Chris Deactive', 'Developer (left company)', 'chris_user', 'chris_user@example.com', 'ROLE_USER', 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=retro&f=y', false], + ['Tony Maier', 'Head of Development', 'tony_teamlead', 'tony_teamlead@example.com', 'ROLE_TEAMLEAD', 'https://en.gravatar.com/userimage/3533186/bf2163b1dd23f3107a028af0195624e9.jpeg', true], + // no avatar to test default image macro + ['Anna Smith', 'Administrator', 'anna_admin', 'anna_admin@example.com', 'ROLE_ADMIN', null, true], + // no alias to test twig username macro + [null, 'Super Administrator', 'susan_super', 'susan_super@example.com', 'ROLE_SUPER_ADMIN', '/bundles/avanzuadmintheme/img/avatar.png', true] ]; } - - protected function getRandomPhrase() - { - return $this->getRandomPostTitle(); - } - - private function getRandomPostTitle() - { - $titles = $this->getPhrases(); - - return $titles[array_rand($titles)]; - } } diff --git a/src/DataFixtures/FixturesTrait.php b/src/DataFixtures/FixturesTrait.php new file mode 100644 index 00000000..9d147e55 --- /dev/null +++ b/src/DataFixtures/FixturesTrait.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\DataFixtures; + +/** + * Defines reusable sample data. + * + * @author Kevin Papst + */ +trait FixturesTrait +{ + /** + * @return string[] + */ + protected function getPhrases() + { + return [ + 'Lorem ipsum dolor sit amet consectetur adipiscing elit', + 'Pellentesque vitae velit ex', + 'Mauris dapibus risus quis suscipit vulputate', + 'Eros diam egestas libero eu vulputate risus', + 'In hac habitasse platea dictumst', + 'Morbi tempus commodo mattis', + 'Ut suscipit posuere justo at vulputate', + 'Ut eleifend mauris et risus ultrices egestas', + 'Aliquam sodales odio id eleifend tristique', + 'Urna nisl sollicitudin id varius orci quam id turpis', + 'Nulla porta lobortis ligula vel egestas', + 'Curabitur aliquam euismod dolor non ornare', + 'Sed varius a risus eget aliquam', + 'Nunc viverra elit ac laoreet suscipit', + 'Pellentesque et sapien pulvinar consectetur', + ]; + } + + /** + * @return string + */ + protected function getRandomPhrase() + { + $phrases = $this->getPhrases(); + return $phrases[array_rand($phrases)]; + } +} diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php new file mode 100644 index 00000000..00554492 --- /dev/null +++ b/src/DataFixtures/InvoiceFixtures.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\DataFixtures; + +use App\Entity\InvoiceTemplate; +use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Common\Persistence\ObjectManager; + +/** + * Defines the sample data to load in the database when running the unit and + * functional tests or while development. + * + * Execute this command to load the data: + * $ php bin/console doctrine:fixtures:load + * + * @author Kevin Papst + */ +class InvoiceFixtures extends Fixture +{ + + /** + * {@inheritdoc} + */ + public function load(ObjectManager $manager) + { + $this->loadInvoiceTemplates($manager); + } + + /** + * @param ObjectManager $manager + */ + private function loadInvoiceTemplates(ObjectManager $manager) + { + $template = new InvoiceTemplate(); + $template + ->setName('Invoice') + ->setTitle('Your company name') + ->setCompany('Kimai, Inc.') + ->setVat(19) + ->setDueDays(14) + ->setPaymentTerms( +'I would like to thank you for your confidence and will gladly be there for you in the future. +Please transfer the total amount within 14 days to the given account and use the invoice number as reference.' + ) + ->setAddress( +'795 Folsom Ave, Suite 600 +San Francisco, CA 94107 +Phone: (804) 123-5432 +Email: info@almasaeedstudio.com' + ) + ; + + $manager->persist($template); + $manager->flush(); + } +} diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php index 0628d913..d872c978 100644 --- a/src/DataFixtures/TimesheetFixtures.php +++ b/src/DataFixtures/TimesheetFixtures.php @@ -16,18 +16,22 @@ use App\Entity\Activity; use App\Entity\Customer; use App\Entity\Project; use App\Entity\Timesheet; +use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Common\Persistence\ObjectManager; /** * Defines the sample data to load in the database when running the unit and - * functional tests. Execute this command to load the data: + * functional tests or while development. * - * $ php bin/console doctrine:fixtures:load + * Execute this command to load the data: + * $ php bin/console doctrine:fixtures:load * * @author Kevin Papst */ -class TimesheetFixtures extends AppFixtures +class TimesheetFixtures extends Fixture { + use FixturesTrait; + const AMOUNT_TIMESHEET = 5000; // timesheet entries total const RATE_MIN = 10; // minimum rate for one hour const RATE_MAX = 80; // maximum rate for one hour @@ -185,11 +189,11 @@ class TimesheetFixtures extends AppFixtures $entry = new Customer(); $entry ->setCurrency($this->getRandomCurrency()) - ->setVat(rand(0, 30)) ->setName($customerName . ($visible ? '' : '.')) ->setAddress($this->getRandomLocation()) ->setComment($this->getRandomPhrase()) ->setVisible($visible) + ->setNumber('C0815-42-' . $i) ->setCountry('DE') // TODO randomize country ? ->setTimezone($allTimezones[rand(1, $amountTimezone)]); diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php new file mode 100644 index 00000000..ef257113 --- /dev/null +++ b/src/DependencyInjection/AppExtension.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\DependencyInjection; + +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Exception\InvalidDefinitionException; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * This is the class that loads and manages your bundle configuration + * + * @author Kevin Papst + */ +class AppExtension extends Extension +{ + /** + * {@inheritdoc} + */ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = new Configuration(); + try { + $config = $this->processConfiguration($configuration, $configs); + } catch (InvalidConfigurationException $e) { + trigger_error('Found invalid "kimai" configuration: ' . $e->getMessage()); + $config = []; + } + + $this->createInvoiceParameter($config, $container); + } + + /** + * @param array $config + * @param ContainerBuilder $container + */ + private function createInvoiceParameter(array $config, ContainerBuilder $container) + { + $keys = ['renderer', 'calculator', 'number_generator']; + + foreach ($keys as $key) { + if (!isset($config['invoice'][$key]) || 0 === count($config['invoice'][$key])) { + throw new InvalidDefinitionException('Missing invoice configuration: kimai.invoice.' . $key); + } + + $container->setParameter('kimai.invoice.' . $key, $config['invoice'][$key]); + } + + $container->setParameter('kimai.invoice', $config['invoice']); + } + + /** + * {@inheritdoc} + */ + public function getAlias() + { + return 'kimai'; + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 3bf6606a..c332697b 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -29,16 +29,46 @@ class Configuration implements ConfigurationInterface $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('kimai'); - // FIXME add kimai config $rootNode ->children() ->arrayNode('timesheet') - ->children() - ->integerNode('round_record')->end() + ->children() + ->integerNode('rounding')->end() + ->end() + ->end() + ->arrayNode('invoice') + ->children() + ->arrayNode('renderer') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue(array( + 'default' => 'App\Controller\InvoiceController::invoiceAction', + )) + ->end() + ->arrayNode('calculator') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue(array( + 'default' => 'App\Invoice\DefaultCalculator', + )) + ->end() + ->arrayNode('number_generator') + ->requiresAtLeastOneElement() + ->useAttributeAsKey('key') + ->isRequired() + ->prototype('scalar')->end() + ->defaultValue(array( + 'default' => 'App\Invoice\DateNumberGenerator', + )) + ->end() + ->end() ->end() ->end() - ->end() - ; + ->end(); return $treeBuilder; } diff --git a/src/Doctrine/TimesheetSubscriber.php b/src/Doctrine/TimesheetSubscriber.php index 635af357..310c961a 100644 --- a/src/Doctrine/TimesheetSubscriber.php +++ b/src/Doctrine/TimesheetSubscriber.php @@ -60,23 +60,25 @@ class TimesheetSubscriber implements EventSubscriber $entity = $args->getObject(); if ($entity instanceof Timesheet) { - $duration = 0; if ($entity->getEnd() !== null) { $duration = $entity->getEnd()->getTimestamp() - $entity->getBegin()->getTimestamp(); $entity->setDuration($duration); // TODO allow to set hourly rate on activity, project and customer and prefer these - $hourlyRate = 0; - foreach ($entity->getUser()->getPreferences() as $preference) { - if ($preference->getName() == UserPreference::HOURLY_RATE) { - $hourlyRate = (int) $preference->getValue(); - } - } - - $rate = $hourlyRate * ($duration / 3600); + $rate = $this->calculateRate($entity); $entity->setRate($rate); } } } + + /** + * @param Timesheet $entity + * @return float + */ + protected function calculateRate(Timesheet $entity) + { + $hourlyRate = (float) $entity->getUser()->getPreferenceValue(UserPreference::HOURLY_RATE, 0); + return (float) $hourlyRate * ($entity->getDuration() / 3600); + } } diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 1ac6945d..3a7308ad 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -44,6 +44,14 @@ class Customer */ private $name; + /** + * @var string + * + * @ORM\Column(name="number", type="string", length=50, nullable=false) + * @Assert\NotBlank() + */ + private $number; + /** * @var string * @@ -73,15 +81,6 @@ class Customer */ private $company; - /** - * @var integer - * - * @ORM\Column(name="vat", type="integer", length=2, nullable=true) - * @Assert\Range(min = 0, max = 99) - * - */ - private $vat; - /** * @var string * @@ -92,7 +91,7 @@ class Customer /** * @var string * - * @ORM\Column(name="street", type="text", length=65535, nullable=true) + * @ORM\Column(name="address", type="text", length=65535, nullable=true) */ private $address; @@ -167,13 +166,11 @@ class Customer * Set name * * @param string $name - * * @return Customer */ public function setName($name) { $this->name = $name; - return $this; } @@ -182,22 +179,38 @@ class Customer * * @return string */ - public function getName() + public function getName(): ?string { return $this->name; } + /** + * @param string $number + * @return Customer + */ + public function setNumber(string $number) + { + $this->number = $number; + return $this; + } + + /** + * @return string + */ + public function getNumber(): ?string + { + return $this->number; + } + /** * Set comment * * @param string $comment - * * @return Customer */ public function setComment($comment) { $this->comment = $comment; - return $this; } @@ -215,13 +228,11 @@ class Customer * Set visible * * @param boolean $visible - * * @return Customer */ public function setVisible($visible) { $this->visible = $visible; - return $this; } @@ -239,13 +250,11 @@ class Customer * Set company * * @param string $company - * * @return Customer */ public function setCompany($company) { $this->company = $company; - return $this; } @@ -259,41 +268,15 @@ class Customer return $this->company; } - /** - * Set vat - * - * @param integer $vat - * - * @return Customer - */ - public function setVat($vat) - { - $this->vat = $vat; - - return $this; - } - - /** - * Get vat - * - * @return integer - */ - public function getVat() - { - return $this->vat; - } - /** * Set contact * * @param string $contact - * * @return Customer */ public function setContact($contact) { $this->contact = $contact; - return $this; } @@ -307,6 +290,16 @@ class Customer return $this->contact; } + /** + * @param string $address + * @return Customer + */ + public function setAddress($address) + { + $this->address = $address; + return $this; + } + /** * @return string */ @@ -315,29 +308,15 @@ class Customer return $this->address; } - /** - * @param string $address - * - * @return Customer - */ - public function setAddress($address) - { - $this->address = $address; - - return $this; - } - /** * Set country * * @param string $country - * * @return Customer */ public function setCountry($country) { $this->country = $country; - return $this; } @@ -351,6 +330,16 @@ class Customer return $this->country; } + /** + * @param string $currency + * @return Customer + */ + public function setCurrency($currency) + { + $this->currency = $currency; + return $this; + } + /** * @return string */ @@ -359,28 +348,15 @@ class Customer return $this->currency; } - /** - * @param string $currency - * @return $this - */ - public function setCurrency($currency) - { - $this->currency = $currency; - - return $this; - } - /** * Set phone * * @param string $phone - * * @return Customer */ public function setPhone($phone) { $this->phone = $phone; - return $this; } @@ -398,13 +374,11 @@ class Customer * Set fax * * @param string $fax - * * @return Customer */ public function setFax($fax) { $this->fax = $fax; - return $this; } @@ -422,13 +396,11 @@ class Customer * Set mobile * * @param string $mobile - * * @return Customer */ public function setMobile($mobile) { $this->mobile = $mobile; - return $this; } @@ -446,13 +418,11 @@ class Customer * Set mail * * @param string $mail - * * @return Customer */ public function setMail($mail) { $this->mail = $mail; - return $this; } @@ -470,13 +440,11 @@ class Customer * Set homepage * * @param string $homepage - * * @return Customer */ public function setHomepage($homepage) { $this->homepage = $homepage; - return $this; } @@ -494,13 +462,11 @@ class Customer * Set timezone * * @param string $timezone - * * @return Customer */ public function setTimezone($timezone) { $this->timezone = $timezone; - return $this; } @@ -516,12 +482,11 @@ class Customer /** * @param Project[] $projects - * @return $this + * @return Customer */ public function setProjects($projects) { $this->projects = $projects; - return $this; } diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php new file mode 100644 index 00000000..c577d221 --- /dev/null +++ b/src/Entity/InvoiceTemplate.php @@ -0,0 +1,317 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * InvoiceTemplate + * + * @ORM\Entity(repositoryClass="App\Repository\InvoiceTemplateRepository") + * @ORM\Table( + * name="invoice_templates", + * uniqueConstraints={ + * @ORM\UniqueConstraint(columns={"name"}) + * } + * ) + * + * @author Kevin Papst + */ +class InvoiceTemplate +{ + /** + * @var integer + * + * @ORM\Column(name="id", type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="IDENTITY") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(name="name", type="string", length=255, nullable=false) + * @Assert\NotBlank() + */ + private $name; + + /** + * @var string + * + * @ORM\Column(name="title", type="string", length=255, nullable=false) + * @Assert\NotBlank() + */ + private $title; + + /** + * @var string + * + * @ORM\Column(name="company", type="string", length=255, nullable=false) + * @Assert\NotBlank() + */ + private $company; + + /** + * @var string + * + * @ORM\Column(name="address", type="text", length=65535, nullable=true) + */ + private $address; + + /** + * @var int + * + * @ORM\Column(name="due_days", type="integer", length=3, nullable=false) + * @Assert\Range(min = 0, max = 999) + */ + private $dueDays = 30; + + /** + * @var float + * + * @ORM\Column(name="vat", type="integer", length=2, nullable=true) + * @Assert\Range(min = 0, max = 99) + */ + private $vat = 0.00; + + /** + * @var string + * + * @ORM\Column(name="calculator", type="string", length=20, nullable=false) + * @Assert\NotBlank() + */ + private $calculator = 'default'; + /** + * @var string + * + * @ORM\Column(name="number_generator", type="string", length=20, nullable=false) + * @Assert\NotBlank() + */ + private $numberGenerator = 'default'; + + /** + * @var string + * + * @ORM\Column(name="renderer", type="string", length=20, nullable=false) + * @Assert\NotBlank() + */ + private $renderer = 'default'; + + /** + * @var string + * + * @ORM\Column(name="payment_terms", type="text", length=65535, nullable=true) + */ + private $paymentTerms; + + /** + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Set name + * + * @param string $name + * @return $this + */ + public function setName($name) + { + $this->name = $name; + return $this; + } + + /** + * Get name + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string + */ + public function getTitle(): ?string + { + return $this->title; + } + + /** + * @param string $title + * @return InvoiceTemplate + */ + public function setTitle(string $title) + { + $this->title = $title; + return $this; + } + + /** + * @return string + */ + public function getAddress(): ?string + { + return $this->address; + } + + /** + * @param string $address + * @return InvoiceTemplate + */ + public function setAddress(string $address) + { + $this->address = $address; + return $this; + } + + /** + * @return string + */ + public function getNumberGenerator(): ?string + { + return $this->numberGenerator; + } + + /** + * @param string $numberGenerator + * @return InvoiceTemplate + */ + public function setNumberGenerator(string $numberGenerator) + { + $this->numberGenerator = $numberGenerator; + return $this; + } + + /** + * @return int + */ + public function getDueDays(): ?int + { + return $this->dueDays; + } + + /** + * @param int $dueDays + * @return InvoiceTemplate + */ + public function setDueDays(int $dueDays) + { + $this->dueDays = $dueDays; + return $this; + } + + /** + * @return float + */ + public function getVat(): ?float + { + return $this->vat; + } + + /** + * @param float $vat + * @return InvoiceTemplate + */ + public function setVat(float $vat) + { + $this->vat = $vat; + return $this; + } + + /** + * @return string + */ + public function getCompany(): ?string + { + return $this->company; + } + + /** + * @param string $company + * @return InvoiceTemplate + */ + public function setCompany(string $company) + { + $this->company = $company; + return $this; + } + + /** + * @return string + */ + public function getRenderer(): string + { + return $this->renderer; + } + + /** + * @param string $renderer + * @return InvoiceTemplate + */ + public function setRenderer(string $renderer) + { + $this->renderer = $renderer; + return $this; + } + + /** + * @return string + */ + public function getCalculator(): string + { + return $this->calculator; + } + + /** + * @param string $calculator + * @return InvoiceTemplate + */ + public function setCalculator(string $calculator) + { + $this->calculator = $calculator; + return $this; + } + + /** + * @return string + */ + public function getPaymentTerms(): ?string + { + return $this->paymentTerms; + } + + /** + * @param string $paymentTerms + * @return InvoiceTemplate + */ + public function setPaymentTerms(string $paymentTerms) + { + $this->paymentTerms = $paymentTerms; + return $this; + } + + /** + * @return string + */ + public function __toString() + { + return $this->getName(); + } +} diff --git a/src/Entity/Timesheet.php b/src/Entity/Timesheet.php index f247bc17..5e866766 100644 --- a/src/Entity/Timesheet.php +++ b/src/Entity/Timesheet.php @@ -90,7 +90,7 @@ class Timesheet private $description; /** - * @var string + * @var float * * @ORM\Column(name="rate", type="decimal", precision=10, scale=2, nullable=false) */ @@ -150,7 +150,6 @@ class Timesheet * Set duration * * @param integer $duration - * * @return Timesheet */ public function setDuration($duration) @@ -182,7 +181,6 @@ class Timesheet * Set user * * @param User $user - * * @return Timesheet */ public function setUser(User $user) @@ -205,7 +203,6 @@ class Timesheet * Set activity * * @param Activity $activity - * * @return Timesheet */ public function setActivity($activity) @@ -228,7 +225,6 @@ class Timesheet * Set description * * @param string $description - * * @return Timesheet */ public function setDescription($description) @@ -250,8 +246,7 @@ class Timesheet /** * Set rate * - * @param string $rate - * + * @param float $rate * @return Timesheet */ public function setRate($rate) @@ -263,7 +258,7 @@ class Timesheet /** * Get rate * - * @return string + * @return float */ public function getRate() { diff --git a/src/Entity/User.php b/src/Entity/User.php index a01b2337..86abd730 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -369,6 +369,35 @@ class User implements UserInterface, AdvancedUserInterface return $this; } + /** + * @param string $name + * @return UserPreference|null + */ + public function getPreference(string $name) + { + foreach ($this->preferences as $preference) { + if ($preference->getName() == $name) { + return $preference; + } + } + + return null; + } + + /** + * @param $name + * @param null $default + * @return bool|int|null|string + */ + public function getPreferenceValue($name, $default = null) + { + $preference = $this->getPreference($name); + if ($preference === null) { + return $default; + } + return $preference->getValue(); + } + /** * @param UserPreference $preference * @return User diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index d402d06a..af15862e 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -59,6 +59,7 @@ class MenuSubscriber implements EventSubscriberInterface $isLoggedIn = $auth->isGranted('IS_AUTHENTICATED_REMEMBERED'); $isUser = $isLoggedIn && $auth->isGranted('ROLE_USER'); + $isTeamlead = $isLoggedIn && $auth->isGranted('ROLE_USER'); if (!$isLoggedIn || !$isUser) { return; @@ -68,6 +69,14 @@ class MenuSubscriber implements EventSubscriberInterface $menu->addItem( new MenuItemModel('timesheet', 'menu.timesheet', 'timesheet', [], 'fa fa-clock-o') ); + + if (!$isTeamlead) { + return; + } + + $menu->addItem( + new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fa fa-print') + ); } /** diff --git a/src/Form/CustomerEditForm.php b/src/Form/CustomerEditForm.php index 61ef9a6a..848985f3 100644 --- a/src/Form/CustomerEditForm.php +++ b/src/Form/CustomerEditForm.php @@ -16,7 +16,6 @@ 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; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -40,84 +39,57 @@ class CustomerEditForm extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options) { $builder - // string - length 255 ->add('name', TextType::class, [ 'label' => 'label.name', ]) - // text + ->add('number', TextType::class, [ + 'label' => 'label.customer_number', + ]) ->add('comment', TextareaType::class, [ 'label' => 'label.comment', 'required' => false, ]) - // do not allow project selection: - // 1. it is a bad UX - // 2. what should happen if they are detached? - /* - ->add('projects', EntityType::class, [ - 'label' => 'label.project', - 'class' => 'Kimai:Project', - 'multiple' => true, - 'expanded' => true - ]) - */ - // boolean ->add('visible', VisibilityType::class, [ 'label' => 'label.visible', ]) - // string - length 255 ->add('company', TextType::class, [ 'label' => 'label.company', 'required' => false, ]) - // string - length 255 - ->add('vat', PercentType::class, [ - 'label' => 'label.vat', - 'type' => 'integer', - ]) - // string - length 255 ->add('contact', TextType::class, [ 'label' => 'label.contact', 'required' => false, ]) - // string - length 255 ->add('address', TextareaType::class, [ 'label' => 'label.address', 'required' => false, ]) - // 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', 'required' => false, ]) - // string - length 255 ->add('fax', TelType::class, [ 'label' => 'label.fax', 'required' => false, ]) - // string - length 255 ->add('mobile', TelType::class, [ 'label' => 'label.mobile', 'required' => false, ]) - // string - length 255 ->add('mail', EmailType::class, [ 'label' => 'label.email', 'required' => false, ]) - // string - length 255 ->add('homepage', UrlType::class, [ 'label' => 'label.homepage', 'required' => false, ]) - // string - length 255 ->add('timezone', TimezoneType::class, [ 'label' => 'label.timezone', ]) diff --git a/src/Form/InvoiceTemplateForm.php b/src/Form/InvoiceTemplateForm.php new file mode 100644 index 00000000..31bb8f34 --- /dev/null +++ b/src/Form/InvoiceTemplateForm.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form; + +use App\Entity\InvoiceTemplate; +use App\Form\Type\InvoiceCalculatorType; +use App\Form\Type\InvoiceNumberGeneratorType; +use App\Form\Type\InvoiceRendererType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\PercentType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Defines the form used to manipulate invoice templates. + * + * @author Kevin Papst + */ +class InvoiceTemplateForm extends AbstractType +{ + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('name', TextType::class, [ + 'label' => 'label.name', + ]) + ->add('title', TextType::class, [ + 'label' => 'label.title', + ]) + ->add('company', TextType::class, [ + 'label' => 'label.company', + ]) + ->add('address', TextareaType::class, [ + 'label' => 'label.address', + 'required' => false, + ]) + ->add('paymentTerms', TextareaType::class, [ + 'label' => 'label.payment_terms', + 'required' => false, + ]) + ->add('dueDays', TextType::class, [ + 'label' => 'label.due_days', + ]) + ->add('vat', PercentType::class, [ + 'label' => 'label.vat', + 'type' => 'integer', + ]) + ->add('renderer', InvoiceRendererType::class, []) + ->add('calculator', InvoiceCalculatorType::class, []) + ->add('numberGenerator', InvoiceNumberGeneratorType::class, []) + ; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => InvoiceTemplate::class, + 'csrf_protection' => true, + 'csrf_field_name' => '_token', + 'csrf_token_id' => 'admin_invoice_template', + ]); + } +} diff --git a/src/Form/Toolbar/AbstractToolbarForm.php b/src/Form/Toolbar/AbstractToolbarForm.php index c86b3971..8ecdf866 100644 --- a/src/Form/Toolbar/AbstractToolbarForm.php +++ b/src/Form/Toolbar/AbstractToolbarForm.php @@ -11,15 +11,33 @@ namespace App\Form\Toolbar; +use App\Form\Type\ActivityType; +use App\Form\Type\CustomerType; +use App\Form\Type\PageSizeType; +use App\Form\Type\ProjectType; +use App\Form\Type\UserRoleType; +use App\Form\Type\UserType; +use App\Form\Type\VisibilityType; +use App\Repository\ActivityRepository; +use App\Repository\CustomerRepository; +use App\Repository\ProjectRepository; +use App\Repository\Query\CustomerQuery; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; /** * Defines the base form used for all toolbars. * + * Extend this class and stack the elements defined here, they are coupled to each other and with the toolbar.js. + * * @author Kevin Papst */ abstract class AbstractToolbarForm extends AbstractType { + /** * Dirty hack to enable easy handling of GET form in controller and javascript. *Cleans up the name of all form elents (and unfortunately of the form itself). @@ -30,4 +48,135 @@ abstract class AbstractToolbarForm extends AbstractType { return ''; } + + /** + * @param FormBuilderInterface $builder + */ + protected function addUserChoice(FormBuilderInterface $builder) + { + $builder->add('user', UserType::class, [ + 'label' => 'label.user', + 'required' => false, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addCustomerChoice(FormBuilderInterface $builder) + { + $builder->add('customer', CustomerType::class, [ + 'required' => false, + 'query_builder' => function (CustomerRepository $repo) { + $query = new CustomerQuery(); + $query->setVisibility(CustomerQuery::SHOW_BOTH); // this field is the reason for the query here + $query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER); + return $repo->findByQuery($query); + }, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addVisibilityChoice(FormBuilderInterface $builder) + { + $builder->add('visibility', VisibilityType::class, []); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addPageSizeChoice(FormBuilderInterface $builder) + { + $builder->add('pageSize', PageSizeType::class, [ + 'required' => false, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addUserRoleChoice(FormBuilderInterface $builder) + { + $builder->add('role', UserRoleType::class, [ + 'required' => false, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addStartDateChoice(FormBuilderInterface $builder) + { + $builder->add('begin', DateType::class, [ + 'label' => 'label.begin', + 'widget' => 'single_text', + 'required' => false, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addEndDateChoice(FormBuilderInterface $builder) + { + $builder->add('end', DateType::class, [ + 'label' => 'label.end', + 'widget' => 'single_text', + 'required' => false, + ]); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addProjectChoice(FormBuilderInterface $builder) + { + $builder->addEventListener( + FormEvents::PRE_SUBMIT, + function (FormEvent $event) { + $data = $event->getData(); + if (!isset($data['customer']) || empty($data['customer'])) { + return; + } + + $event->getForm()->add('project', ProjectType::class, array( + 'group_by' => null, + 'required' => false, + 'query_builder' => function (ProjectRepository $repo) use ($data) { + $qb = $repo->builderForEntityType(); + $qb->where('p.customer = :customer')->setParameter('customer', $data['customer']); + return $qb; + }, + )); + } + ); + } + + /** + * @param FormBuilderInterface $builder + */ + protected function addActivityChoice(FormBuilderInterface $builder) + { + $builder->addEventListener( + FormEvents::PRE_SUBMIT, + function (FormEvent $event) { + $data = $event->getData(); + if (!isset($data['project']) || empty($data['project'])) { + return; + } + + $event->getForm()->add('activity', ActivityType::class, array( + 'group_by' => null, + 'required' => false, + 'query_builder' => function (ActivityRepository $repo) use ($data) { + $qb = $repo->builderForEntityType(); + $qb->where('a.project = :project')->setParameter('project', $data['project']); + return $qb; + }, + )); + } + ); + } } diff --git a/src/Form/Toolbar/ActivityToolbarForm.php b/src/Form/Toolbar/ActivityToolbarForm.php index 6ef123ba..b4486206 100644 --- a/src/Form/Toolbar/ActivityToolbarForm.php +++ b/src/Form/Toolbar/ActivityToolbarForm.php @@ -13,7 +13,6 @@ namespace App\Form\Toolbar; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use App\Form\Type\ProjectType; use App\Repository\Query\ActivityQuery; /** @@ -21,7 +20,7 @@ use App\Repository\Query\ActivityQuery; * * @author Kevin Papst */ -class ActivityToolbarForm extends ProjectToolbarForm +class ActivityToolbarForm extends AbstractToolbarForm { /** @@ -29,25 +28,10 @@ class ActivityToolbarForm extends ProjectToolbarForm */ public function buildForm(FormBuilderInterface $builder, array $options) { - parent::buildForm($builder, $options); - - /** @var ActivityQuery $query */ - $query = $options['data']; - - if ($query->getCustomer() === null) { - return; - } - - $choices = []; - foreach ($query->getCustomer()->getProjects() as $project) { - $choices[] = $project; - } - - $builder - ->add('project', ProjectType::class, [ - 'required' => false, - 'choices' => $choices, - ]); + $this->addPageSizeChoice($builder); + $this->addVisibilityChoice($builder); + $this->addCustomerChoice($builder); + $this->addProjectChoice($builder); } /** diff --git a/src/Form/Toolbar/CustomerToolbarForm.php b/src/Form/Toolbar/CustomerToolbarForm.php index 6adafe90..0ca8d1c7 100644 --- a/src/Form/Toolbar/CustomerToolbarForm.php +++ b/src/Form/Toolbar/CustomerToolbarForm.php @@ -11,7 +11,7 @@ namespace App\Form\Toolbar; -use App\Form\Toolbar\VisibilityToolbarForm; +use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use App\Repository\Query\CustomerQuery; @@ -20,9 +20,18 @@ use App\Repository\Query\CustomerQuery; * * @author Kevin Papst */ -class CustomerToolbarForm extends VisibilityToolbarForm +class CustomerToolbarForm extends AbstractToolbarForm { + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $this->addPageSizeChoice($builder); + $this->addVisibilityChoice($builder); + } + /** * {@inheritdoc} */ diff --git a/src/Form/Toolbar/InvoiceToolbarForm.php b/src/Form/Toolbar/InvoiceToolbarForm.php new file mode 100644 index 00000000..cf5dc165 --- /dev/null +++ b/src/Form/Toolbar/InvoiceToolbarForm.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Toolbar; + +use App\Form\Type\InvoiceTemplateType; +use App\Repository\Query\InvoiceQuery; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Defines the form used for filtering timesheet entries for invoices. + * + * @author Kevin Papst + */ +class InvoiceToolbarForm extends AbstractToolbarForm +{ + + /** + * @inheritdoc + */ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $this->addTemplateChoice($builder); + $this->addUserChoice($builder); + $this->addStartDateChoice($builder); + $this->addEndDateChoice($builder); + $this->addCustomerChoice($builder); + $this->addProjectChoice($builder); + $this->addActivityChoice($builder); + } + + protected function addTemplateChoice(FormBuilderInterface $builder) + { + $builder->add('template', InvoiceTemplateType::class, [ + 'required' => true + ]); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => InvoiceQuery::class, + 'csrf_protection' => false, + ]); + } +} diff --git a/src/Form/Toolbar/PagedToolbarForm.php b/src/Form/Toolbar/PagedToolbarForm.php deleted file mode 100644 index 02213a19..00000000 --- a/src/Form/Toolbar/PagedToolbarForm.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace App\Form\Toolbar; - -use App\Form\Type\PageSizeType; -use Symfony\Component\Form\FormBuilderInterface; -use App\Repository\Query\CustomerQuery; - -/** - * Defines the base form used for all toolbars with pageSizes. - * - * @author Kevin Papst - */ -class PagedToolbarForm extends AbstractToolbarForm -{ - - /** - * {@inheritdoc} - */ - public function buildForm(FormBuilderInterface $builder, array $options) - { - /** @var CustomerQuery $query */ - $query = $options['data']; - - $builder - ->add('pageSize', PageSizeType::class, [ - 'required' => false, - ]) - ; - } -} diff --git a/src/Form/Toolbar/ProjectToolbarForm.php b/src/Form/Toolbar/ProjectToolbarForm.php index a5d99f7e..c8ca9dbd 100644 --- a/src/Form/Toolbar/ProjectToolbarForm.php +++ b/src/Form/Toolbar/ProjectToolbarForm.php @@ -11,12 +11,8 @@ namespace App\Form\Toolbar; -use App\Form\Toolbar\VisibilityToolbarForm; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use App\Form\Type\CustomerType; -use App\Repository\CustomerRepository; -use App\Repository\Query\CustomerQuery; use App\Repository\Query\ProjectQuery; /** @@ -24,7 +20,7 @@ use App\Repository\Query\ProjectQuery; * * @author Kevin Papst */ -class ProjectToolbarForm extends VisibilityToolbarForm +class ProjectToolbarForm extends AbstractToolbarForm { /** @@ -32,19 +28,9 @@ class ProjectToolbarForm extends VisibilityToolbarForm */ public function buildForm(FormBuilderInterface $builder, array $options) { - parent::buildForm($builder, $options); - - $builder - ->add('customer', CustomerType::class, [ - 'required' => false, - 'query_builder' => function (CustomerRepository $repo) { - $query = new CustomerQuery(); - $query->setVisibility(CustomerQuery::SHOW_BOTH); // this field is the reason for the query here - $query->setResultType(CustomerQuery::RESULT_TYPE_QUERYBUILDER); - return $repo->findByQuery($query); - }, - ]) - ; + $this->addPageSizeChoice($builder); + $this->addVisibilityChoice($builder); + $this->addCustomerChoice($builder); } /** diff --git a/src/Form/Toolbar/VisibilityToolbarForm.php b/src/Form/Toolbar/TimesheetAdminToolbarForm.php similarity index 54% rename from src/Form/Toolbar/VisibilityToolbarForm.php rename to src/Form/Toolbar/TimesheetAdminToolbarForm.php index 20108072..1e1ca7f4 100644 --- a/src/Form/Toolbar/VisibilityToolbarForm.php +++ b/src/Form/Toolbar/TimesheetAdminToolbarForm.php @@ -11,28 +11,26 @@ namespace App\Form\Toolbar; -use App\Form\Type\VisibilityType; use Symfony\Component\Form\FormBuilderInterface; /** - * Defines the form used for filtering entities with a "visibility" field. + * Defines the form used for filtering the admin timesheet. * * @author Kevin Papst */ -class VisibilityToolbarForm extends PagedToolbarForm +class TimesheetAdminToolbarForm extends TimesheetToolbarForm { /** - * {@inheritdoc} + * @inheritdoc */ public function buildForm(FormBuilderInterface $builder, array $options) { - parent::buildForm($builder, $options); - - $builder - ->add('visibility', VisibilityType::class, [ - 'required' => false, - ]) - ; + $this->addTimesheetStateChoice($builder); + $this->addPageSizeChoice($builder); + $this->addUserChoice($builder); + $this->addCustomerChoice($builder); + $this->addProjectChoice($builder); + $this->addActivityChoice($builder); } } diff --git a/src/Form/Toolbar/TimesheetToolbarForm.php b/src/Form/Toolbar/TimesheetToolbarForm.php index 63a8b8b5..d9c778f7 100644 --- a/src/Form/Toolbar/TimesheetToolbarForm.php +++ b/src/Form/Toolbar/TimesheetToolbarForm.php @@ -14,7 +14,6 @@ namespace App\Form\Toolbar; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; -use App\Form\Type\ActivityType; use App\Repository\Query\TimesheetQuery; /** @@ -22,7 +21,7 @@ use App\Repository\Query\TimesheetQuery; * * @author Kevin Papst */ -class TimesheetToolbarForm extends ActivityToolbarForm +class TimesheetToolbarForm extends AbstractToolbarForm { /** @@ -30,42 +29,26 @@ class TimesheetToolbarForm extends ActivityToolbarForm */ public function buildForm(FormBuilderInterface $builder, array $options) { - $builder - ->add('state', ChoiceType::class, [ - 'label' => 'label.entryState', - 'choices' => [ - 'entryState.all' => TimesheetQuery::STATE_ALL, - 'entryState.running' => TimesheetQuery::STATE_RUNNING, - 'entryState.stopped' => TimesheetQuery::STATE_STOPPED - ], - ]) - ; - parent::buildForm($builder, $options); - $this->addActivityChoice($builder, $options['data']); - - $builder->remove('visibility'); + $this->addTimesheetStateChoice($builder); + $this->addPageSizeChoice($builder); + $this->addCustomerChoice($builder); + $this->addProjectChoice($builder); + $this->addActivityChoice($builder); } /** * @param FormBuilderInterface $builder - * @param TimesheetQuery $query */ - protected function addActivityChoice(FormBuilderInterface $builder, TimesheetQuery $query) + protected function addTimesheetStateChoice(FormBuilderInterface $builder) { - if ($query->getProject() === null) { - return; - } - - $choices = []; - foreach ($query->getProject()->getActivities() as $activity) { - $choices[] = $activity; - } - - $builder - ->add('activity', ActivityType::class, [ - 'required' => false, - 'choices' => $choices, - ]); + $builder->add('state', ChoiceType::class, [ + 'label' => 'label.entryState', + 'choices' => [ + 'entryState.all' => TimesheetQuery::STATE_ALL, + 'entryState.running' => TimesheetQuery::STATE_RUNNING, + 'entryState.stopped' => TimesheetQuery::STATE_STOPPED + ], + ]); } /** diff --git a/src/Form/Toolbar/UserToolbarForm.php b/src/Form/Toolbar/UserToolbarForm.php index 28399a75..e0a5cdbb 100644 --- a/src/Form/Toolbar/UserToolbarForm.php +++ b/src/Form/Toolbar/UserToolbarForm.php @@ -11,7 +11,6 @@ namespace App\Form\Toolbar; -use App\Form\Type\UserRoleType; use App\Repository\Query\UserQuery; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -21,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; * * @author Kevin Papst */ -class UserToolbarForm extends VisibilityToolbarForm +class UserToolbarForm extends AbstractToolbarForm { /** @@ -29,13 +28,9 @@ class UserToolbarForm extends VisibilityToolbarForm */ public function buildForm(FormBuilderInterface $builder, array $options) { - parent::buildForm($builder, $options); - - $builder - ->add('role', UserRoleType::class, [ - 'required' => false, - ]) - ; + $this->addPageSizeChoice($builder); + $this->addVisibilityChoice($builder); + $this->addUserRoleChoice($builder); } /** diff --git a/src/Form/Type/InvoiceCalculatorType.php b/src/Form/Type/InvoiceCalculatorType.php new file mode 100644 index 00000000..6c55903e --- /dev/null +++ b/src/Form/Type/InvoiceCalculatorType.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Type; + +use App\Invoice\ServiceInvoice; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Custom form field type to select an invoice calculator. + * + * @author Kevin Papst + */ +class InvoiceCalculatorType extends AbstractType +{ + /** + * @var ServiceInvoice + */ + protected $service; + + /** + * InvoiceCalculatorType constructor. + * @param ServiceInvoice $service + */ + public function __construct(ServiceInvoice $service) + { + $this->service = $service; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $renderer = []; + foreach ($this->service->getCalculator() as $name => $class) { + $renderer[$name] = $name; + } + + $resolver->setDefaults([ + 'label' => 'label.invoice_calculator', + 'choices' => $renderer, + 'choice_label' => function($renderer) { + return 'invoice_calculator.' . $renderer; + } + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Form/Type/InvoiceNumberGeneratorType.php b/src/Form/Type/InvoiceNumberGeneratorType.php new file mode 100644 index 00000000..9e2d105e --- /dev/null +++ b/src/Form/Type/InvoiceNumberGeneratorType.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Type; + +use App\Invoice\ServiceInvoice; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Custom form field type to select an invoice number generator. + * + * @author Kevin Papst + */ +class InvoiceNumberGeneratorType extends AbstractType +{ + /** + * @var ServiceInvoice + */ + protected $service; + + /** + * InvoiceNumberGeneratorType constructor. + * @param ServiceInvoice $service + */ + public function __construct(ServiceInvoice $service) + { + $this->service = $service; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $renderer = []; + foreach ($this->service->getNumberGenerator() as $name => $class) { + $renderer[$name] = $name; + } + + $resolver->setDefaults([ + 'label' => 'label.invoice_number_generator', + 'choices' => $renderer, + 'choice_label' => function($renderer) { + return 'invoice_number_generator.' . $renderer; + } + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Form/Type/InvoiceRendererType.php b/src/Form/Type/InvoiceRendererType.php new file mode 100644 index 00000000..82ab8ea8 --- /dev/null +++ b/src/Form/Type/InvoiceRendererType.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Type; + +use App\Invoice\ServiceInvoice; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Custom form field type to select an invoice renderer. + * + * @author Kevin Papst + */ +class InvoiceRendererType extends AbstractType +{ + /** + * @var ServiceInvoice + */ + protected $service; + + /** + * InvoiceRendererType constructor. + * @param ServiceInvoice $service + */ + public function __construct(ServiceInvoice $service) + { + $this->service = $service; + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $renderer = []; + foreach ($this->service->getRenderer() as $name => $action) { + $renderer[$name] = $name; + } + + $resolver->setDefaults([ + 'label' => 'label.invoice_renderer', + 'choices' => $renderer, + 'choice_label' => function($renderer) { + return 'invoice_renderer.' . $renderer; + } + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return ChoiceType::class; + } +} diff --git a/src/Form/Type/InvoiceTemplateType.php b/src/Form/Type/InvoiceTemplateType.php new file mode 100644 index 00000000..7c80b868 --- /dev/null +++ b/src/Form/Type/InvoiceTemplateType.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Form\Type; + +use App\Entity\InvoiceTemplate; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * Custom form field type to select an invoice template. + * + * @author Kevin Papst + */ +class InvoiceTemplateType extends AbstractType +{ + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'label' => 'label.template', + 'class' => InvoiceTemplate::class, + ]); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return EntityType::class; + } +} diff --git a/src/Form/Type/ProjectType.php b/src/Form/Type/ProjectType.php index ee5b0904..60a32164 100644 --- a/src/Form/Type/ProjectType.php +++ b/src/Form/Type/ProjectType.php @@ -16,7 +16,6 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolver; use App\Entity\Project; use App\Repository\ProjectRepository; -use App\Repository\Query\ProjectQuery; /** * Custom form field type to select a project. @@ -33,7 +32,7 @@ class ProjectType extends AbstractType { $resolver->setDefaults([ 'label' => 'label.project', - 'class' => 'Kimai:Project', + 'class' => Project::class, 'choice_label' => 'name', 'group_by' => function (Project $project, $key, $index) { return $project->getCustomer()->getName(); diff --git a/src/Form/Type/VisibilityType.php b/src/Form/Type/VisibilityType.php index 11dc4998..5aa6c089 100644 --- a/src/Form/Type/VisibilityType.php +++ b/src/Form/Type/VisibilityType.php @@ -32,6 +32,7 @@ class VisibilityType extends AbstractType $resolver->setDefaults([ 'label' => 'label.visible', 'choices' => [ + 'both' => VisibilityQuery::SHOW_BOTH, 'yes' => VisibilityQuery::SHOW_VISIBLE, 'no' => VisibilityQuery::SHOW_HIDDEN, ], diff --git a/src/Invoice/CalculatorInterface.php b/src/Invoice/CalculatorInterface.php new file mode 100644 index 00000000..3e0d1d7e --- /dev/null +++ b/src/Invoice/CalculatorInterface.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +use App\Model\InvoiceModel; + +/** + * CalculatorInterface defines all methods for any invoice price calculator. + * + * @author Kevin Papst + */ +interface CalculatorInterface +{ + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model); + + /** + * @return float + */ + public function getSubtotal(): float; + + /** + * @return float + */ + public function getTax(): float; + + /** + * @return float + */ + public function getTotal(): float; + + /** + * @return string + */ + public function getCurrency(): string; +} diff --git a/src/Invoice/DateNumberGenerator.php b/src/Invoice/DateNumberGenerator.php new file mode 100644 index 00000000..a9bc6ded --- /dev/null +++ b/src/Invoice/DateNumberGenerator.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +use App\Model\InvoiceModel; + +/** + * Class DateNumberGenerator generates the invoice number based on the current day. + * It will create duplicate IDs if you create multiple invoices per day. + * + * @author Kevin Papst + */ +class DateNumberGenerator implements NumberGeneratorInterface +{ + /** + * @var InvoiceModel + */ + protected $model; + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model) + { + $this->model = $model; + } + + /** + * @return string + */ + public function getInvoiceNumber(): string + { + return date('ymd'); + } +} diff --git a/src/Invoice/DefaultCalculator.php b/src/Invoice/DefaultCalculator.php new file mode 100644 index 00000000..d8170141 --- /dev/null +++ b/src/Invoice/DefaultCalculator.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +use App\Model\InvoiceModel; + +/** + * Class DefaultCalculator works on all given entries using: + * - the customers currency + * - the invoice template vat rate + * - the entries rate + * + * @author Kevin Papst + */ +class DefaultCalculator implements CalculatorInterface +{ + + /** + * @var string + */ + protected $currency; + + /** + * @var InvoiceModel + */ + protected $model; + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model) + { + $this->model = $model; + } + + /** + * @return float + */ + public function getSubtotal(): float + { + $amount = 0; + foreach ($this->model->getEntries() as $entry) { + $amount += $entry->getRate(); + } + return round($amount, 2); + } + + /** + * @return float + */ + public function getVat() + { + return $this->model->getTemplate()->getVat(); + } + + /** + * @return float + */ + public function getTax(): float + { + $vat = $this->getVat(); + if ($vat == 0) { + return 0; + } + + $percent = $vat / 100.00; + return round($this->getSubtotal() * $percent, 2); + } + + /** + * @return float + */ + public function getTotal(): float + { + return $this->getSubtotal() + $this->getTax(); + } + + /** + * @return string + */ + public function getCurrency(): string + { + return $this->model->getCustomer()->getCurrency(); + } +} diff --git a/src/Invoice/NumberGeneratorInterface.php b/src/Invoice/NumberGeneratorInterface.php new file mode 100644 index 00000000..c07a00bb --- /dev/null +++ b/src/Invoice/NumberGeneratorInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +use App\Model\InvoiceModel; + +/** + * Class NumberGeneratorInterface defines all methods that invoice number generator have to implement. + * + * @author Kevin Papst + */ +interface NumberGeneratorInterface +{ + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model); + + /** + * @return string + */ + public function getInvoiceNumber(): string; +} diff --git a/src/Invoice/RandomNumberGenerator.php b/src/Invoice/RandomNumberGenerator.php new file mode 100644 index 00000000..fb91be08 --- /dev/null +++ b/src/Invoice/RandomNumberGenerator.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +use App\Model\InvoiceModel; + +/** + * Class RandomNumberGenerator is meant for testing purpose only. + * + * @author Kevin Papst + */ +class RandomNumberGenerator implements NumberGeneratorInterface +{ + /** + * @var InvoiceModel + */ + protected $model; + + /** + * @param InvoiceModel $model + */ + public function setModel(InvoiceModel $model) + { + $this->model = $model; + } + + /** + * @return string + */ + public function getInvoiceNumber(): string + { + return rand(1000000, 9999999); + } +} diff --git a/src/Invoice/ServiceInvoice.php b/src/Invoice/ServiceInvoice.php new file mode 100644 index 00000000..081d069c --- /dev/null +++ b/src/Invoice/ServiceInvoice.php @@ -0,0 +1,106 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Invoice; + +/** + * A service to manage the invoice configuration: + * - invoice number generator + * - invoice sum calculator + * - template renderer + * + * @author Kevin Papst + */ +class ServiceInvoice +{ + /** + * @var array + */ + protected $config = []; + + /** + * ServiceInvoice constructor. + * @param array $invoiceConfig + */ + public function __construct(array $invoiceConfig) + { + $this->config = $invoiceConfig; + } + + /** + * @return array + */ + public function getNumberGenerator() + { + return $this->config['number_generator']; + } + + /** + * @param string $name + * @return NumberGeneratorInterface|null + */ + public function getNumberGeneratorByName(string $name) + { + foreach ($this->getNumberGenerator() as $key => $class) { + if ($key === $name) { + return new $class(); + } + } + return null; + } + + /** + * @return array + */ + public function getCalculator() + { + return $this->config['calculator']; + } + + /** + * @param string $name + * @return CalculatorInterface|null + */ + public function getCalculatorByName(string $name) + { + foreach ($this->getCalculator() as $key => $class) { + if ($key === $name) { + return new $class(); + } + } + return null; + } + + /** + * Returns an array of invoice renderer, which will consist of a unique name and a controller action. + * + * @return array + * @throws \Exception + */ + public function getRenderer() + { + return $this->config['renderer']; + } + + /** + * @param $renderer + * @return string|null + */ + public function getRendererActionByName($renderer) + { + foreach ($this->config['renderer'] as $name => $action) { + if ($name == $renderer) { + return $action; + } + } + return null; + } +} diff --git a/src/Kernel.php b/src/Kernel.php index 303c347d..5ea948d7 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -2,6 +2,7 @@ namespace App; +use App\DependencyInjection\AppExtension; use App\DependencyInjection\Compiler\DoctrineCompilerPass; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; @@ -38,6 +39,8 @@ class Kernel extends BaseKernel protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader) { + $container->registerExtension(new AppExtension()); + $container->setParameter('container.autowiring.strict_mode', true); $container->setParameter('container.dumper.inline_class_loader', true); $confDir = $this->getProjectDir().'/config'; diff --git a/src/Model/InvoiceModel.php b/src/Model/InvoiceModel.php new file mode 100644 index 00000000..1bb4ac19 --- /dev/null +++ b/src/Model/InvoiceModel.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Model; + +use App\Entity\Customer; +use App\Entity\InvoiceTemplate; +use App\Entity\Timesheet; +use App\Invoice\CalculatorInterface; +use App\Invoice\DateNumberGenerator; +use App\Invoice\DefaultCalculator; +use App\Invoice\NumberGeneratorInterface; +use App\Repository\Query\InvoiceQuery; + +/** + * Class InvoiceModel is the ONLY value that a renderer template receives for generating the invoice. + * + * @author Kevin Papst + */ +class InvoiceModel +{ + + /** + * @var Customer + */ + protected $customer; + + /** + * @var InvoiceQuery + */ + protected $query; + + /** + * @var Timesheet[] + */ + protected $entries; + + /** + * @var InvoiceTemplate + */ + protected $template; + + /** + * @var CalculatorInterface + */ + protected $calculator; + + /** + * @var NumberGeneratorInterface + */ + protected $generator; + + /** + * @return InvoiceQuery + */ + public function getQuery(): InvoiceQuery + { + return $this->query; + } + + /** + * @param InvoiceQuery $query + * @return InvoiceModel + */ + public function setQuery(InvoiceQuery $query) + { + $this->query = $query; + return $this; + } + + /** + * @return Timesheet[] + */ + public function getEntries(): array + { + return $this->entries; + } + + /** + * @param Timesheet[] $entries + * @return InvoiceModel + */ + public function setEntries(array $entries) + { + $this->entries = $entries; + return $this; + } + + /** + * @return InvoiceTemplate + */ + public function getTemplate(): ?InvoiceTemplate + { + return $this->template; + } + + /** + * @param InvoiceTemplate $template + * @return InvoiceModel + */ + public function setTemplate($template) + { + $this->template = $template; + return $this; + } + + /** + * @return Customer + */ + public function getCustomer() + { + return $this->customer; + } + + /** + * @param Customer $customer + * @return InvoiceModel + */ + public function setCustomer($customer) + { + $this->customer = $customer; + return $this; + } + + /** + * @return \DateTime + */ + public function getDueDate(): \DateTime + { + return new \DateTime('+'.$this->getTemplate()->getDueDays().' days'); + } + + /** + * @return \DateTime + */ + public function getInvoiceDate(): \DateTime + { + return new \DateTime(); + } + + /** + * @param CalculatorInterface $calculator + * @return InvoiceModel + */ + public function setCalculator(CalculatorInterface $calculator) + { + $this->calculator = $calculator; + $this->calculator->setModel($this); + return $this; + } + + /** + * @param NumberGeneratorInterface $generator + * @return InvoiceModel + */ + public function setNumberGenerator(NumberGeneratorInterface $generator) + { + $this->generator = $generator; + $this->generator->setModel($this); + return $this; + } + + /** + * @return NumberGeneratorInterface + */ + public function getNumberGenerator(): NumberGeneratorInterface + { + return $this->generator; + } + + /** + * @return CalculatorInterface + */ + public function getCalculator(): CalculatorInterface + { + return $this->calculator; + } +} diff --git a/src/Repository/InvoiceTemplateRepository.php b/src/Repository/InvoiceTemplateRepository.php new file mode 100644 index 00000000..a2e09c52 --- /dev/null +++ b/src/Repository/InvoiceTemplateRepository.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Repository; + +use App\Entity\InvoiceTemplate; +use App\Repository\Query\BaseQuery; +use Doctrine\ORM\Query; + +/** + * Class InvoiceTemplateRepository + * + * @author Kevin Papst + */ +class InvoiceTemplateRepository extends AbstractRepository +{ + + /** + * @return bool + */ + public function hasTemplate() + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb->select('COUNT(t.id) as totalRecords') + ->from(InvoiceTemplate::class, 't') + ; + + $result = $qb->getQuery()->execute([], Query::HYDRATE_ARRAY); + + if (!isset($result[0])) { + return false; + } + + return $result[0]['totalRecords'] > 0; + } + + /** + * @param BaseQuery $query + * @return \Doctrine\ORM\QueryBuilder|\Pagerfanta\Pagerfanta + */ + public function findByQuery(BaseQuery $query) + { + $qb = $this->getEntityManager()->createQueryBuilder(); + + $qb->select('t') + ->from(InvoiceTemplate::class, 't') + ->orderBy('t.id'); + + return $this->getBaseQueryResult($qb, $query); + } +} diff --git a/src/Repository/Query/ActivityQuery.php b/src/Repository/Query/ActivityQuery.php index 8d61d80d..da19803d 100644 --- a/src/Repository/Query/ActivityQuery.php +++ b/src/Repository/Query/ActivityQuery.php @@ -19,7 +19,7 @@ use App\Entity\Project; * * @author Kevin Papst */ -class ActivityQuery extends VisibilityQuery +class ActivityQuery extends ProjectQuery { /** @@ -27,29 +27,6 @@ class ActivityQuery extends VisibilityQuery */ protected $project; - /** - * @var Customer - */ - protected $customer; - - /** - * @return Customer - */ - public function getCustomer() - { - return $this->customer; - } - - /** - * @param Customer $customer - * @return $this - */ - public function setCustomer(Customer $customer = null) - { - $this->customer = $customer; - return $this; - } - /** * @return Project */ diff --git a/src/Repository/Query/InvoiceQuery.php b/src/Repository/Query/InvoiceQuery.php new file mode 100644 index 00000000..254203d6 --- /dev/null +++ b/src/Repository/Query/InvoiceQuery.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Repository\Query; + +use App\Entity\InvoiceTemplate; + +/** + * Can be used for invoice queries. + * + * @author Kevin Papst + */ +class InvoiceQuery extends TimesheetQuery +{ + + /** + * @var InvoiceTemplate + */ + protected $template; + + /** + * @var InvoiceTemplate[] + */ + protected $templates = []; + + /** + * @return InvoiceTemplate + */ + public function getTemplate() + { + return $this->template; + } + + /** + * @param InvoiceTemplate $template + * @return InvoiceQuery + */ + public function setTemplate($template) + { + $this->template = $template; + return $this; + } + + /** + * @return InvoiceTemplate[] + */ + public function getTemplates(): array + { + return $this->templates; + } + + /** + * @param InvoiceTemplate[] $templates + * @return InvoiceQuery + */ + public function setTemplates(array $templates) + { + $this->templates = $templates; + return $this; + } +} diff --git a/src/Repository/Query/TimesheetQuery.php b/src/Repository/Query/TimesheetQuery.php index 3175aeeb..07e7917f 100644 --- a/src/Repository/Query/TimesheetQuery.php +++ b/src/Repository/Query/TimesheetQuery.php @@ -22,7 +22,7 @@ use App\Entity\Project; * * @author Kevin Papst */ -class TimesheetQuery extends BaseQuery +class TimesheetQuery extends ActivityQuery { const STATE_ALL = 1; @@ -47,18 +47,18 @@ class TimesheetQuery extends BaseQuery * @var Activity */ protected $activity; - /** - * @var Project - */ - protected $project; - /** - * @var Customer - */ - protected $customer; /** * @var int */ protected $state = self::STATE_ALL; + /** + * @var \DateTime + */ + protected $begin; + /** + * @var \DateTime + */ + protected $end; /** * @return User @@ -98,48 +98,6 @@ class TimesheetQuery extends BaseQuery return $this; } - /** - * @return Project - */ - public function getProject() - { - return $this->project; - } - - /** - * Project overwrites: setCustomer() - * Is overwritten by: setActivity() - * - * @param Project $project - * @return TimesheetQuery - */ - public function setProject(Project $project = null) - { - $this->project = $project; - return $this; - } - - /** - * @return Customer - */ - public function getCustomer() - { - return $this->customer; - } - - /** - * Project overwrites: none - * Is overwritten by: setActivity() and setProject() - * - * @param Customer $customer - * @return TimesheetQuery - */ - public function setCustomer(Customer $customer = null) - { - $this->customer = $customer; - return $this; - } - /** * @return int */ @@ -165,4 +123,40 @@ class TimesheetQuery extends BaseQuery return $this; } + + /** + * @return \DateTime + */ + public function getBegin() + { + return $this->begin; + } + + /** + * @param \DateTime $begin + * @return TimesheetQuery + */ + public function setBegin($begin) + { + $this->begin = $begin; + return $this; + } + + /** + * @return \DateTime + */ + public function getEnd() + { + return $this->end; + } + + /** + * @param \DateTime $end + * @return TimesheetQuery + */ + public function setEnd($end) + { + $this->end = $end; + return $this; + } } diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index 934f88be..61fa93a9 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -15,6 +15,7 @@ use App\Entity\User; use App\Entity\Activity; use App\Entity\Timesheet; use Doctrine\DBAL\Types\Type; +use Doctrine\ORM\QueryBuilder; use Pagerfanta\Pagerfanta; use App\Model\Statistic\Month; use App\Model\Statistic\Year; @@ -266,7 +267,7 @@ class TimesheetRepository extends AbstractRepository /** * @param TimesheetQuery $query - * @return Pagerfanta + * @return QueryBuilder|Pagerfanta */ public function findByQuery(TimesheetQuery $query) { @@ -291,6 +292,15 @@ class TimesheetRepository extends AbstractRepository $qb->andWhere($qb->expr()->isNotNull('t.end')); } + if ($query->getBegin() !== null) { + $qb->andWhere('t.begin >= :begin') + ->setParameter('begin', $query->getBegin()); + } + if ($query->getEnd() !== null) { + $qb->andWhere('t.end <= :end') + ->setParameter('end', $query->getEnd()); + } + if ($query->getActivity() !== null) { $qb->andWhere('t.activity = :activity') ->setParameter('activity', $query->getActivity()); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 0426213f..3d6d3f2e 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -23,6 +23,14 @@ use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface; */ class UserRepository extends AbstractRepository implements UserLoaderInterface { + /** + * @param $id + * @return null|User + */ + public function getById($id) + { + return $this->find($id); + } /** * Return statistic data for all user. diff --git a/src/Twig/Extensions.php b/src/Twig/Extensions.php index fcea5336..ec6bc37c 100644 --- a/src/Twig/Extensions.php +++ b/src/Twig/Extensions.php @@ -11,9 +11,7 @@ namespace App\Twig; -use App\Utils\Markdown; use Symfony\Component\Intl\Intl; -use App\Entity\Customer; use App\Entity\Timesheet; /** @@ -123,8 +121,11 @@ class Extensions extends \Twig_Extension */ public function money($amount, $currency = null) { - $currency = $currency ?: Customer::DEFAULT_CURRENCY; - return round($amount) . ' ' . Intl::getCurrencyBundle()->getCurrencySymbol($currency); + $result = round($amount, 2); + if ($currency !== null) { + $result .= ' ' . Intl::getCurrencyBundle()->getCurrencySymbol($currency); + } + return $result; } /** diff --git a/templates/admin/customer.html.twig b/templates/admin/customer.html.twig index d4e17ae9..0020aa2a 100644 --- a/templates/admin/customer.html.twig +++ b/templates/admin/customer.html.twig @@ -11,11 +11,9 @@ {{ widgets.callout('warning', 'error.no_entries_found') }} {% endif %} - {# Available fields: vat; contact; address; country; phone; fax; mobile; mail; homepage; timezone; #} - {{ tables.data_table_header({ - 'label.id': 'hidden-xs', 'label.name': '', + 'label.customer_number': 'hidden-xs', 'label.project': '', 'label.comment': 'hidden-xs', 'label.country': 'hidden-xs', @@ -26,8 +24,8 @@ {% for entry in entries %} - {{ entry.id }} {{ entry.name }} {% if entry.company is not empty %}({{ entry.company }}){% endif %} + {{ entry.number }} {% for project in entry.projects %} {{ widgets.label_project(project) }} diff --git a/templates/base.html.twig b/templates/base.html.twig index 6dfaf53c..6c990ac2 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,5 +1,15 @@ {% extends '@AvanzuAdminTheme/layout/default-layout.html.twig' %} +{% block avanzu_page_content_before %} +
+ {% block main_before %}{% endblock %} +
+{% endblock %} + +{% block avanzu_page_content_after %} + {% block main_after %}{% endblock %} +{% endblock %} + {% block avanzu_page_content %} {{ include('default/_flash_messages.html.twig') }} {% block main %}{% endblock %} @@ -31,16 +41,17 @@ {% block avanzu_head %} + {##} {% endblock %} {% block avanzu_footer %} {% block footer %} -