diff --git a/README.md b/README.md index acf7457ee..a41b34851 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do ### About -This is new version of the open source timetracker Kimai. It is stable and production ready, ships +This is the new version of the open source timetracker Kimai. It is stable and production ready, ships with most advanced features from Kimai 1 and many new ones, including but not limited to: JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones, @@ -59,9 +59,9 @@ You can see a rough development roadmap in the [Milestones](https://github.com/k It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome. > Kimai 2 uses a rolling release concept for delivering updates. -> You can upgrade Kimai at any time, you don't need to wait for the next official release. +> You can upgrade Kimai at any time , you don't need to wait for the next official release. -Release versions will be created on a regular base (approx. one release per month) and you can use these tags if you are familiar with git. +Release versions will be created on a regular base (approx. one release per month) and you can should use these tags if you are not familiar with git. Every code change, whether it's a new feature or a bug fix, will be done on the master branch. I have to do it this way, as I develop Kimai in my free time and want to put my effort into the software instead of backporting changes for old versions. diff --git a/UPGRADING.md b/UPGRADING.md index 1b95a3c2c..b267acaa5 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -8,14 +8,15 @@ you can upgrade your Kimai installation to the latest stable release. Check below if there are more version specific steps required, which need to be executed after the normal update process. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. -## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6) and [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1) +## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2) **New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).** -- Invoice improvements: +- Invoice changes: - Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates). Using them? Install them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html)). - Added new invoice fields (VAT, contact, payment details) and customer field (VAT). Used the twig settings before? Move them to the respective invoice template settings. - Permissions can be managed via Admin UI. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html). +- Important permission change: regular users with the `view_other_timesheet` permission could see all timesheets. This was a legacy from the time before team permissions were introduced. If you rely on this behavior, you need to create a team with all users and the teamlead being the user who needs access to all timesheets. ### Developer diff --git a/composer.json b/composer.json index bb20a5410..8e678df6a 100644 --- a/composer.json +++ b/composer.json @@ -32,8 +32,8 @@ "nelmio/api-doc-bundle": "^3.2", "nelmio/cors-bundle": "^1.5", "ocramius/proxy-manager": "^2.1.1", - "phpoffice/phpspreadsheet": "^1.4", - "phpoffice/phpword": "^0.15.0", + "phpoffice/phpspreadsheet": "^1.10", + "phpoffice/phpword": "^0.17", "psr/log": "^1.1", "sensio/framework-extra-bundle": "^5.2", "symfony/asset": "^4.0", diff --git a/composer.lock b/composer.lock index 191dd8c15..c1839deab 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c71764ce6f4c135070a2f7e7a0c62b6", + "content-hash": "c08938efb681d04751323b133c8d8199", "packages": [ { "name": "beberlei/doctrineextensions", @@ -4525,16 +4525,16 @@ }, { "name": "phpoffice/phpword", - "version": "0.15.0", + "version": "0.17.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PHPWord.git", - "reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f" + "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f", - "reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f", + "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/b8346af548d399acd9e30fc76ab0c55c2fec03a5", + "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5", "shasum": "" }, "require": { @@ -4566,7 +4566,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-develop": "0.16-dev" + "dev-develop": "0.18-dev" } }, "autoload": { @@ -4587,6 +4587,10 @@ "email": "me@gabrielbull.com", "homepage": "http://gabrielbull.com/" }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net/blog/" + }, { "name": "Ivan Lanin", "homepage": "http://ivan.lanin.org" @@ -4595,10 +4599,6 @@ "name": "Roman Syroeshko", "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" }, - { - "name": "Franck Lefevre", - "homepage": "https://rootslabs.net/blog/" - }, { "name": "Antoine de Troostembergh" } @@ -4630,7 +4630,7 @@ "word", "writer" ], - "time": "2018-07-14T16:59:43+00:00" + "time": "2019-10-01T20:43:33+00:00" }, { "name": "psr/cache", diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index f4bcb7d17..d169b44c2 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -28,7 +28,7 @@ doctrine: entity_managers: default: connection: default - naming_strategy: doctrine.orm.naming_strategy.underscore + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true mappings: App: diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 24c0be7ee..981d336e8 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -21,9 +21,9 @@ nelmio_api_doc: - { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] } - - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] } + - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] } - { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] } - - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] } + - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] } - { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } areas: diff --git a/config/serializer/App/Entity.Team.yml b/config/serializer/App/Entity.Team.yml index 9b47f46c9..e177e6074 100644 --- a/config/serializer/App/Entity.Team.yml +++ b/config/serializer/App/Entity.Team.yml @@ -1,13 +1,17 @@ App\Entity\Team: exclusion_policy: All - custom_accessor_order: [id, name, users] + custom_accessor_order: [id, name, teamlead, users] properties: id: include: true name: include: true + teamlead: + include: true + groups: [Team_Entity] users: - exclude: true + include: true + groups: [Team_Entity] customers: exclude: true projects: diff --git a/config/serializer/App/Entity.User.yml b/config/serializer/App/Entity.User.yml index 8907f719e..1bfdae5a3 100644 --- a/config/serializer/App/Entity.User.yml +++ b/config/serializer/App/Entity.User.yml @@ -1,6 +1,6 @@ App\Entity\User: exclusion_policy: All - custom_accessor_order: [id, alias, title, avatar, language, timezone] + custom_accessor_order: [id, alias, title, avatar, language, timezone, teams] properties: id: include: true @@ -10,18 +10,21 @@ App\Entity\User: groups: [Default] title: include: true - groups: [Entity] + groups: [User_Entity] avatar: include: true - groups: [Entity] + groups: [User_Entity] + teams: + include: true + groups: [User_Entity] virtual_properties: getLanguage: serialized_name: language exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')" type: string - groups: [Entity] + groups: [User_Entity] getTimezone: serialized_name: timezone exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')" type: string - groups: [Entity] + groups: [User_Entity] diff --git a/config/serializer/FOS/UserBundle/Model.User.yml b/config/serializer/FOS/UserBundle/Model.User.yml index 6b28923fc..8266de181 100644 --- a/config/serializer/FOS/UserBundle/Model.User.yml +++ b/config/serializer/FOS/UserBundle/Model.User.yml @@ -10,7 +10,7 @@ FOS\UserBundle\Model\User: roles: type: array include: true - groups: [Entity] + groups: [User_Entity] groups: exclude: true email: diff --git a/src/API/TagController.php b/src/API/TagController.php index 16a7323db..04245f00f 100644 --- a/src/API/TagController.php +++ b/src/API/TagController.php @@ -28,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @RouteResource("Tag") + * + * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')") */ class TagController extends BaseApiController { diff --git a/src/API/TeamController.php b/src/API/TeamController.php index 3d0cd02b7..62b71315d 100644 --- a/src/API/TeamController.php +++ b/src/API/TeamController.php @@ -24,6 +24,8 @@ use Symfony\Component\HttpFoundation\Response; /** * @RouteResource("Team") + * + * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')") */ class TeamController extends BaseApiController { @@ -78,6 +80,8 @@ class TeamController extends BaseApiController * @SWG\Schema(ref="#/definitions/TeamEntity"), * ) * + * @Security("is_granted('view_team')") + * * @ApiSecurity(name="apiUser") * @ApiSecurity(name="apiToken") */ @@ -90,7 +94,7 @@ class TeamController extends BaseApiController } $view = new View($data, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'Team']); + $view->getContext()->setGroups(['Default', 'Entity', 'Team', 'Team_Entity']); return $this->viewHandler->handle($view); } diff --git a/src/API/UserController.php b/src/API/UserController.php index a6e0fa0da..7efc190db 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -133,7 +133,7 @@ class UserController extends BaseApiController } $view = new View($user, 200); - $view->getContext()->setGroups(['Default', 'Entity', 'User']); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); return $this->viewHandler->handle($view); } @@ -155,7 +155,7 @@ class UserController extends BaseApiController public function meAction(): Response { $view = new View($this->getUser(), 200); - $view->getContext()->setGroups(['Default', 'Entity', 'User']); + $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']); return $this->viewHandler->handle($view); } diff --git a/src/Constants.php b/src/Constants.php index ff73a79f8..7d1ab6d11 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -17,7 +17,7 @@ class Constants /** * The current release version */ - public const VERSION = '1.6.1'; + public const VERSION = '1.6.2'; /** * The current release status, either "stable" or "dev" */ diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index eae1bad23..496827d92 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -10,8 +10,11 @@ namespace App\Controller; use App\Entity\InvoiceTemplate; +use App\Event\InvoicePostRenderEvent; +use App\Event\InvoicePreRenderEvent; use App\Form\InvoiceTemplateForm; use App\Form\Toolbar\InvoiceToolbarForm; +use App\Invoice\InvoiceFormatter; use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceModel; use App\Invoice\ServiceInvoice; @@ -25,6 +28,7 @@ use Symfony\Component\Form\SubmitButton; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; /** * Controller used to create invoices and manage invoice templates. @@ -32,26 +36,36 @@ use Symfony\Component\Routing\Annotation\Route; * @Route(path="/invoice") * @Security("is_granted('view_invoice')") */ -class InvoiceController extends AbstractController +final class InvoiceController extends AbstractController { /** * @var ServiceInvoice */ - protected $service; + private $service; /** * @var InvoiceTemplateRepository */ - protected $invoiceRepository; + private $invoiceRepository; /** * @var UserDateTimeFactory */ - protected $dateTimeFactory; + private $dateTimeFactory; + /** + * @var InvoiceFormatter + */ + private $formatter; + /** + * @var EventDispatcherInterface + */ + private $dispatcher; - public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory) + public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher) { $this->service = $service; $this->invoiceRepository = $invoice; $this->dateTimeFactory = $dateTimeFactory; + $this->formatter = $formatter; + $this->dispatcher = $dispatcher; } /** @@ -115,8 +129,14 @@ class InvoiceController extends AbstractController $query->setEnd($end); $query->setExported(InvoiceQuery::STATE_NOT_EXPORTED); $query->setState(InvoiceQuery::STATE_STOPPED); + // limit access to data from teams $query->setCurrentUser($this->getUser()); + if (!$this->isGranted('view_other_timesheet')) { + // limit access to own data + $query->setUser($this->getUser()); + } + return $query; } @@ -135,11 +155,15 @@ class InvoiceController extends AbstractController foreach ($this->service->getRenderer() as $renderer) { if ($renderer->supports($document)) { + $this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer)); + $response = $renderer->render($document, $model); if ($query->isMarkAsExported()) { $this->markEntriesAsExported($entries); } + $this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response)); + return $response; } } @@ -216,9 +240,10 @@ class InvoiceController extends AbstractController */ protected function prepareModel(InvoiceQuery $query): InvoiceModel { - $model = new InvoiceModel(); + $model = new InvoiceModel($this->formatter); $model ->setQuery($query) + ->setUser($this->getUser()) ->setCustomer($query->getCustomer()) ; @@ -340,6 +365,7 @@ class InvoiceController extends AbstractController return $this->createForm(InvoiceToolbarForm::class, $query, [ 'action' => $this->generateUrl('invoice', []), 'method' => $method, + 'include_user' => $this->isGranted('view_other_timesheet'), 'attr' => [ 'id' => 'invoice-print-form' ], diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 30bebfef6..e1a88264f 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -10,6 +10,7 @@ namespace App\Controller; use App\Entity\User; +use App\Entity\UserPreference; use App\Event\PrepareUserEvent; use App\Form\UserApiTokenType; use App\Form\UserEditType; @@ -20,6 +21,7 @@ use App\Form\UserTeamsType; use App\Repository\TeamRepository; use App\Repository\TimesheetRepository; use App\Voter\UserVoter; +use Doctrine\Common\Collections\ArrayCollection; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\FormInterface; @@ -210,6 +212,13 @@ class ProfileController extends AbstractController $event = new PrepareUserEvent($profile); $this->dispatcher->dispatch($event); + /** @var \ArrayIterator $iterator */ + $iterator = $profile->getPreferences()->getIterator(); + $iterator->uasort(function (UserPreference $a, UserPreference $b) { + return ($a->getOrder() < $b->getOrder()) ? -1 : 1; + }); + $profile->setPreferences(new ArrayCollection(iterator_to_array($iterator))); + $original = []; foreach ($profile->getPreferences() as $preference) { $original[$preference->getName()] = $preference; diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index c61cb3163..d6eb064d6 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -402,7 +402,7 @@ abstract class TimesheetAbstractController extends AbstractController 'action' => $this->generateUrl($this->getMultiUpdateRoute(), []), 'method' => 'POST', 'include_exported' => $this->isGranted($this->getPermissionEditExport()), - 'include_user' => $this->includeUserInForms(), + 'include_user' => $this->includeUserInForms('multi'), ]); } @@ -426,7 +426,7 @@ abstract class TimesheetAbstractController extends AbstractController 'action' => $this->generateUrl($this->getCreateRoute()), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_exported' => $this->isGranted('edit_export', $entry), - 'include_user' => $this->includeUserInForms(), + 'include_user' => $this->includeUserInForms('create'), 'allow_begin_datetime' => $mode->canEditBegin(), 'allow_end_datetime' => $mode->canEditEnd(), 'allow_duration' => $mode->canEditDuration(), @@ -450,7 +450,7 @@ abstract class TimesheetAbstractController extends AbstractController ]), 'include_rate' => $this->isGranted('edit_rate', $entry), 'include_exported' => $this->isGranted('edit_export', $entry), - 'include_user' => $this->includeUserInForms(), + 'include_user' => $this->includeUserInForms('edit'), 'allow_begin_datetime' => $mode->canEditBegin(), 'allow_end_datetime' => $mode->canEditEnd(), 'allow_duration' => $mode->canEditDuration(), @@ -469,7 +469,7 @@ abstract class TimesheetAbstractController extends AbstractController 'page' => $query->getPage(), ]), 'method' => 'GET', - 'include_user' => $this->includeUserInForms(), + 'include_user' => $this->includeUserInForms('toolbar'), ]); } @@ -493,7 +493,7 @@ abstract class TimesheetAbstractController extends AbstractController return (bool) $this->getUser()->getPreferenceValue('timesheet.daily_stats', false); } - protected function includeUserInForms(): bool + protected function includeUserInForms(string $formName): bool { return false; } diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index c67314cf1..4ffcfcc61 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -118,9 +118,13 @@ class TimesheetTeamController extends TimesheetAbstractController return TimesheetAdminEditForm::class; } - protected function includeUserInForms(): bool + protected function includeUserInForms(string $formName): bool { - return true; + if ($formName === 'toolbar') { + return true; + } + + return $this->isGranted('edit_other_timesheet'); } protected function getTimesheetRoute(): string diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php index c75b2df8b..dee25b10b 100644 --- a/src/DataFixtures/InvoiceFixtures.php +++ b/src/DataFixtures/InvoiceFixtures.php @@ -69,7 +69,7 @@ class InvoiceFixtures extends Fixture $paymentTerms_alt = $faker->firstName . ', thank you very much. We really appreciate your business.' . PHP_EOL . - 'Please send payments before the due date.I would like to thank you for your confidence and will gladly be there for you in the future.' + 'Please send payments before the due date. I would like to thank you for your confidence and will gladly be there for you in the future.' ; $paymentTerms_de = @@ -114,7 +114,6 @@ class InvoiceFixtures extends Fixture protected function generateAddress(Generator $faker) { return - 'Kimai Inc.' . PHP_EOL . $faker->streetAddress . PHP_EOL . $faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode ; diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php index 0aa5aea65..75eea4d7b 100644 --- a/src/DependencyInjection/AppExtension.php +++ b/src/DependencyInjection/AppExtension.php @@ -45,6 +45,9 @@ class AppExtension extends Extension $config['timesheet']['rounding'][$name]['days'] = implode(',', $settings['days']); } + $config['invoice']['documents'] = array_merge($config['invoice']['documents'], $config['invoice']['defaults']); + unset($config['invoice']['defaults']); + // safe alternatives to %kernel.project_dir% $container->setParameter('kimai.data_dir', $config['data_dir']); $container->setParameter('kimai.plugin_dir', $config['plugin_dir']); diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a47c21b25..f180d2d2a 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -211,14 +211,18 @@ class Configuration implements ConfigurationInterface $node ->addDefaultsIfNotSet() ->children() - ->arrayNode('documents') - ->requiresAtLeastOneElement() + ->arrayNode('defaults') ->scalarPrototype()->end() ->defaultValue([ 'var/invoices/', 'templates/invoice/renderer/' ]) ->end() + ->arrayNode('documents') + ->requiresAtLeastOneElement() + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() ->end() ; diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php index 5a22abe88..734c10bb9 100644 --- a/src/Entity/InvoiceTemplate.php +++ b/src/Entity/InvoiceTemplate.php @@ -99,6 +99,7 @@ class InvoiceTemplate * * @ORM\Column(name="calculator", type="string", length=20, nullable=false) * @Assert\NotBlank() + * @Assert\Length(max=20) */ private $calculator = 'default'; /** @@ -106,6 +107,7 @@ class InvoiceTemplate * * @ORM\Column(name="number_generator", type="string", length=20, nullable=false) * @Assert\NotBlank() + * @Assert\Length(max=20) */ private $numberGenerator = 'default'; @@ -114,6 +116,7 @@ class InvoiceTemplate * * @ORM\Column(name="renderer", type="string", length=20, nullable=false) * @Assert\NotBlank() + * @Assert\Length(max=20) */ private $renderer = 'default'; diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 1682f210f..d551e514b 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -77,6 +77,10 @@ class UserPreference * @var array */ private $options = []; + /** + * @var int + */ + private $order = 1000; /** * @return int @@ -246,4 +250,16 @@ class UserPreference return $this->name; } + + public function getOrder(): int + { + return $this->order; + } + + public function setOrder(int $order): UserPreference + { + $this->order = $order; + + return $this; + } } diff --git a/src/Event/InvoicePostRenderEvent.php b/src/Event/InvoicePostRenderEvent.php new file mode 100644 index 000000000..c861e1ff8 --- /dev/null +++ b/src/Event/InvoicePostRenderEvent.php @@ -0,0 +1,64 @@ +model = $model; + $this->document = $document; + $this->renderer = $renderer; + $this->response = $response; + } + + public function getModel(): InvoiceModel + { + return $this->model; + } + + public function getDocument(): InvoiceDocument + { + return $this->document; + } + + public function getRenderer(): RendererInterface + { + return $this->renderer; + } + + public function getResponse(): Response + { + return $this->response; + } +} diff --git a/src/Event/InvoicePreRenderEvent.php b/src/Event/InvoicePreRenderEvent.php new file mode 100644 index 000000000..5870a383f --- /dev/null +++ b/src/Event/InvoicePreRenderEvent.php @@ -0,0 +1,53 @@ +model = $model; + $this->document = $document; + $this->renderer = $renderer; + } + + public function getModel(): InvoiceModel + { + return $this->model; + } + + public function getDocument(): InvoiceDocument + { + return $this->document; + } + + public function getRenderer(): RendererInterface + { + return $this->renderer; + } +} diff --git a/src/EventSubscriber/DashboardSubscriber.php b/src/EventSubscriber/DashboardSubscriber.php index 20ef9389f..ab026d2ea 100644 --- a/src/EventSubscriber/DashboardSubscriber.php +++ b/src/EventSubscriber/DashboardSubscriber.php @@ -14,6 +14,10 @@ use App\Event\DashboardEvent; use App\Repository\ActivityRepository; use App\Repository\CustomerRepository; use App\Repository\ProjectRepository; +use App\Repository\Query\ActivityQuery; +use App\Repository\Query\CustomerQuery; +use App\Repository\Query\ProjectQuery; +use App\Repository\Query\UserQuery; use App\Repository\UserRepository; use App\Widget\Type\CompoundRow; use App\Widget\Type\More; @@ -82,8 +86,9 @@ class DashboardSubscriber implements EventSubscriberInterface */ public function onDashboardEvent(DashboardEvent $event) { + $user = $event->getUser(); $section = new CompoundRow(); - $section->setTitle('ROLE_ADMIN'); + $section->setTitle(''); $section->setOrder(100); if ($this->security->isGranted('view_user')) { @@ -91,7 +96,7 @@ class DashboardSubscriber implements EventSubscriberInterface (new More()) ->setId('userTotal') ->setTitle('stats.userTotal') - ->setData($this->user->countUser()) + ->setData($this->user->countUsersForQuery((new UserQuery())->setCurrentUser($user))) ->setOptions([ 'route' => 'admin_user', 'icon' => 'user', @@ -105,7 +110,7 @@ class DashboardSubscriber implements EventSubscriberInterface (new More()) ->setId('customerTotal') ->setTitle('stats.customerTotal') - ->setData($this->customer->countCustomer()) + ->setData($this->customer->countCustomersForQuery((new CustomerQuery())->setCurrentUser($user))) ->setOptions([ 'route' => 'admin_customer', 'icon' => 'customer', @@ -119,7 +124,7 @@ class DashboardSubscriber implements EventSubscriberInterface (new More()) ->setId('projectTotal') ->setTitle('stats.projectTotal') - ->setData($this->project->countProject()) + ->setData($this->project->countProjectsForQuery((new ProjectQuery())->setCurrentUser($user))) ->setOptions([ 'route' => 'admin_project', 'icon' => 'project', @@ -133,7 +138,7 @@ class DashboardSubscriber implements EventSubscriberInterface (new More()) ->setId('activityTotal') ->setTitle('stats.activityTotal') - ->setData($this->activity->countActivity()) + ->setData($this->activity->countActivitiesForQuery((new ActivityQuery())->setCurrentUser($user))) ->setOptions([ 'route' => 'admin_activity', 'icon' => 'activity', diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index 57c64a423..45f304d8e 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -72,7 +72,7 @@ final class MenuSubscriber implements EventSubscriberInterface if ($auth->isGranted('view_invoice')) { $invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice')); - $invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create']); + $invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create', 'admin_invoice_template_copy']); $menu->addItem($invoice); } diff --git a/src/EventSubscriber/UserPreferenceSubscriber.php b/src/EventSubscriber/UserPreferenceSubscriber.php index 7e7e0aa54..04e59c1b5 100644 --- a/src/EventSubscriber/UserPreferenceSubscriber.php +++ b/src/EventSubscriber/UserPreferenceSubscriber.php @@ -104,6 +104,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface (new UserPreference()) ->setName(UserPreference::HOURLY_RATE) ->setValue(0) + ->setOrder(100) ->setType(MoneyType::class) ->setEnabled($enableHourlyRate) ->setOptions($hourlyRateOptions) @@ -112,36 +113,43 @@ class UserPreferenceSubscriber implements EventSubscriberInterface (new UserPreference()) ->setName(UserPreference::TIMEZONE) ->setValue($this->getDefaultTimezone()) + ->setOrder(200) ->setType(TimezoneType::class), (new UserPreference()) ->setName(UserPreference::LOCALE) ->setValue($this->getDefaultLanguage()) + ->setOrder(300) ->setType(LanguageType::class), (new UserPreference()) ->setName(UserPreference::SKIN) ->setValue($this->getDefaultTheme()) + ->setOrder(400) ->setType(SkinType::class), (new UserPreference()) ->setName('theme.collapsed_sidebar') ->setValue(false) + ->setOrder(500) ->setType(CheckboxType::class), (new UserPreference()) ->setName('calendar.initial_view') ->setValue(CalendarViewType::DEFAULT_VIEW) + ->setOrder(600) ->setType(CalendarViewType::class), (new UserPreference()) ->setName('login.initial_view') ->setValue(InitialViewType::DEFAULT_VIEW) + ->setOrder(700) ->setType(InitialViewType::class), (new UserPreference()) ->setName('timesheet.daily_stats') ->setValue(false) + ->setOrder(800) ->setType(CheckboxType::class), ]; } @@ -153,29 +161,22 @@ class UserPreferenceSubscriber implements EventSubscriberInterface { $user = $event->getUser(); - $prefs = []; - foreach ($user->getPreferences() as $preference) { - $prefs[$preference->getName()] = $preference; - } - $event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user)); $this->eventDispatcher->dispatch($event); foreach ($event->getPreferences() as $preference) { - /* @var UserPreference[] $prefs */ - if (isset($prefs[$preference->getName()])) { - /* @var UserPreference $pref */ - $prefs[$preference->getName()] + $userPref = $user->getPreference($preference->getName()); + if (null !== $userPref) { + $userPref ->setType($preference->getType()) ->setConstraints($preference->getConstraints()) ->setEnabled($preference->isEnabled()) ->setOptions($preference->getOptions()) + ->setOrder($preference->getOrder()) ; } else { - $prefs[$preference->getName()] = $preference; + $user->addPreference($preference); } } - - $user->setPreferences(array_values($prefs)); } } diff --git a/src/Form/Toolbar/InvoiceToolbarForm.php b/src/Form/Toolbar/InvoiceToolbarForm.php index ad70cc424..755a79916 100644 --- a/src/Form/Toolbar/InvoiceToolbarForm.php +++ b/src/Form/Toolbar/InvoiceToolbarForm.php @@ -28,7 +28,9 @@ class InvoiceToolbarForm extends AbstractToolbarForm { $this->addSearchTermInputField($builder); $this->addTemplateChoice($builder); - $this->addUsersChoice($builder); + if ($options['include_user']) { + $this->addUsersChoice($builder); + } $this->addDateRangeChoice($builder); $this->addCustomerChoice($builder, true); $this->addProjectChoice($builder); @@ -64,6 +66,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm $resolver->setDefaults([ 'data_class' => InvoiceQuery::class, 'csrf_protection' => false, + 'include_user' => true, ]); } } diff --git a/src/Invoice/Calculator/AbstractMergedCalculator.php b/src/Invoice/Calculator/AbstractMergedCalculator.php index 790401ba9..dc191bee7 100644 --- a/src/Invoice/Calculator/AbstractMergedCalculator.php +++ b/src/Invoice/Calculator/AbstractMergedCalculator.php @@ -13,6 +13,7 @@ use App\Entity\Timesheet; use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemWithAmountInterface; +use App\Invoice\InvoiceItemWithTypeInterface; abstract class AbstractMergedCalculator extends AbstractCalculator { @@ -37,6 +38,25 @@ abstract class AbstractMergedCalculator extends AbstractCalculator if ($entry instanceof InvoiceItemWithAmountInterface) { $amount = $entry->getAmount(); } + + if ($entry instanceof InvoiceItemWithTypeInterface) { + $type = $entry->getInvoiceType(); + $category = $entry->getInvoiceCategory(); + } else { + $type = InvoiceItem::TYPE_TIMESHEET; + $category = InvoiceItem::CATEGORY_TIMESHEET_WORK; + } + + if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) { + $type = InvoiceItem::TYPE_MIXED; + } + if (null !== $invoiceItem->getCategory() && $category !== $invoiceItem->getCategory()) { + $category = InvoiceItem::CATEGORY_MIXED; + } + + $invoiceItem->setType($type); + $invoiceItem->setCategory($category); + $invoiceItem->setAmount($invoiceItem->getAmount() + $amount); $invoiceItem->setUser($entry->getUser()); $invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate()); diff --git a/src/Invoice/DefaultInvoiceFormatter.php b/src/Invoice/DefaultInvoiceFormatter.php new file mode 100644 index 000000000..fc22456bd --- /dev/null +++ b/src/Invoice/DefaultInvoiceFormatter.php @@ -0,0 +1,104 @@ +translator = $translator; + $this->dateExtension = $dateExtension; + $this->extension = $extensions; + } + + /** + * @param \DateTime $date + * @return mixed + */ + public function getFormattedDateTime(\DateTime $date) + { + return $this->dateExtension->dateShort($date); + } + + /** + * @param \DateTime $date + * @return mixed + */ + public function getFormattedTime(\DateTime $date) + { + return $this->dateExtension->time($date); + } + + /** + * @param int $amount + * @param string $currency + * @return string + */ + public function getFormattedMoney($amount, $currency) + { + return $this->extension->money($amount, $currency); + } + + /** + * @param \DateTime $date + * @return mixed + */ + public function getFormattedMonthName(\DateTime $date) + { + return $this->translator->trans($this->dateExtension->monthName($date)); + } + + /** + * @param int $seconds + * @return mixed + */ + public function getFormattedDuration($seconds) + { + return $this->extension->duration($seconds); + } + + /** + * @param int $seconds + * @return mixed + */ + public function getFormattedDecimalDuration($seconds) + { + return $this->extension->durationDecimal($seconds); + } + + public function getCurrencySymbol(string $currency): string + { + return $this->extension->currency($currency); + } +} diff --git a/src/Invoice/InvoiceFormatter.php b/src/Invoice/InvoiceFormatter.php new file mode 100644 index 000000000..38c81139d --- /dev/null +++ b/src/Invoice/InvoiceFormatter.php @@ -0,0 +1,55 @@ +type; + } + + public function setType(string $type): InvoiceItem + { + $this->type = $type; + + return $this; + } + + public function getCategory(): ?string + { + return $this->category; + } + + public function setCategory(string $category): InvoiceItem + { + $this->category = $category; + + return $this; + } } diff --git a/src/Invoice/InvoiceItemWithTypeInterface.php b/src/Invoice/InvoiceItemWithTypeInterface.php new file mode 100644 index 000000000..4cc6ffb82 --- /dev/null +++ b/src/Invoice/InvoiceItemWithTypeInterface.php @@ -0,0 +1,17 @@ +invoiceDate = new \DateTime(); + $this->formatter = $formatter; } /** @@ -183,4 +188,242 @@ class InvoiceModel { return $this->calculator; } + + /** + * Returns the user who is currently creating the invoice. + * + * @return User|null + */ + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): InvoiceModel + { + $this->user = $user; + + return $this; + } + + public function getFormatter(): ?InvoiceFormatter + { + return $this->formatter; + } + + public function toArray(): array + { + $model = $this; + $customer = $model->getCustomer(); + $project = $model->getQuery()->getProject(); + $activity = $model->getQuery()->getActivity(); + $currency = $model->getCalculator()->getCurrency(); + $tax = $model->getCalculator()->getTax(); + $total = $model->getCalculator()->getTotal(); + $subtotal = $model->getCalculator()->getSubtotal(); + $formatter = $model->getFormatter(); + + $values = [ + 'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()), + 'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()), + 'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(), + 'invoice.currency' => $currency, + 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency), + 'invoice.vat' => $model->getCalculator()->getVat(), + 'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), + 'invoice.tax_nc' => $formatter->getFormattedMoney($tax, null), + 'invoice.tax_plain' => $tax, + 'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()), + 'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()), + 'invoice.total' => $formatter->getFormattedMoney($total, $currency), + 'invoice.total_nc' => $formatter->getFormattedMoney($total, null), + 'invoice.total_plain' => $total, + 'invoice.subtotal' => $formatter->getFormattedMoney($subtotal, $currency), + 'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, null), + 'invoice.subtotal_plain' => $subtotal, + + 'template.name' => $model->getTemplate()->getName(), + 'template.company' => $model->getTemplate()->getCompany(), + 'template.address' => $model->getTemplate()->getAddress(), + 'template.title' => $model->getTemplate()->getTitle(), + 'template.payment_terms' => $model->getTemplate()->getPaymentTerms(), + 'template.due_days' => $model->getTemplate()->getDueDays(), + 'template.vat_id' => $model->getTemplate()->getVatId(), + 'template.contact' => $model->getTemplate()->getContact(), + 'template.payment_details' => $model->getTemplate()->getPaymentDetails(), + + 'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()), + 'query.day' => $model->getQuery()->getBegin()->format('d'), + 'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()), + 'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()), + 'query.month_number' => $model->getQuery()->getBegin()->format('m'), + 'query.year' => $model->getQuery()->getBegin()->format('Y'), + ]; + + if (null !== $model->getUser()) { + $user = $model->getUser(); + + /** @var UserPreference $metaField */ + foreach ($user->getPreferences() as $metaField) { + $values = array_merge($values, [ + 'user.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } + + $values = array_merge($values, [ + 'user.name' => $user->getUsername(), + 'user.email' => $user->getEmail(), + 'user.title' => $user->getTitle(), + 'user.alias' => $user->getAlias(), + ]); + } + + if (null !== $activity) { + $values = array_merge($values, [ + 'activity.id' => $activity->getId(), + 'activity.name' => $activity->getName(), + 'activity.comment' => $activity->getComment(), + 'activity.fixed_rate' => $formatter->getFormattedMoney($activity->getFixedRate(), $currency), + 'activity.fixed_rate_nc' => $formatter->getFormattedMoney($activity->getFixedRate(), null), + 'activity.fixed_rate_plain' => $activity->getFixedRate(), + 'activity.hourly_rate' => $formatter->getFormattedMoney($activity->getHourlyRate(), $currency), + 'activity.hourly_rate_nc' => $formatter->getFormattedMoney($activity->getHourlyRate(), null), + 'activity.hourly_rate_plain' => $activity->getHourlyRate(), + ]); + + foreach ($activity->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'activity.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } + } + + if (null !== $project) { + $values = array_merge($values, [ + 'project.id' => $project->getId(), + 'project.name' => $project->getName(), + 'project.comment' => $project->getComment(), + 'project.order_number' => $project->getOrderNumber(), + 'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '', + 'project.fixed_rate' => $formatter->getFormattedMoney($project->getFixedRate(), $currency), + 'project.fixed_rate_nc' => $formatter->getFormattedMoney($project->getFixedRate(), null), + 'project.fixed_rate_plain' => $project->getFixedRate(), + 'project.hourly_rate' => $formatter->getFormattedMoney($project->getHourlyRate(), $currency), + 'project.hourly_rate_nc' => $formatter->getFormattedMoney($project->getHourlyRate(), null), + 'project.hourly_rate_plain' => $project->getHourlyRate(), + ]); + + foreach ($project->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'project.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } + } + + if (null !== $customer) { + $values = array_merge($values, [ + 'customer.id' => $customer->getId(), + 'customer.address' => $customer->getAddress(), + 'customer.name' => $customer->getName(), + 'customer.contact' => $customer->getContact(), + 'customer.company' => $customer->getCompany(), + 'customer.vat' => $customer->getVatId(), + 'customer.number' => $customer->getNumber(), + 'customer.country' => $customer->getCountry(), + 'customer.homepage' => $customer->getHomepage(), + 'customer.comment' => $customer->getComment(), + 'customer.fixed_rate' => $formatter->getFormattedMoney($customer->getFixedRate(), $currency), + 'customer.fixed_rate_nc' => $formatter->getFormattedMoney($customer->getFixedRate(), null), + 'customer.fixed_rate_plain' => $customer->getFixedRate(), + 'customer.hourly_rate' => $formatter->getFormattedMoney($customer->getHourlyRate(), $currency), + 'customer.hourly_rate_nc' => $formatter->getFormattedMoney($customer->getHourlyRate(), null), + 'customer.hourly_rate_plain' => $customer->getHourlyRate(), + ]); + + foreach ($customer->getVisibleMetaFields() as $metaField) { + $values = array_merge($values, [ + 'customer.meta.' . $metaField->getName() => $metaField->getValue(), + ]); + } + } + + return $values; + } + + public function itemToArray(InvoiceItem $invoiceItem): array + { + $formatter = $this->getFormatter(); + + $rate = $invoiceItem->getRate(); + $appliedRate = $invoiceItem->getHourlyRate(); + $amount = $formatter->getFormattedDuration($invoiceItem->getDuration()); + $description = $invoiceItem->getDescription(); + + if ($invoiceItem->isFixedRate()) { + $appliedRate = $invoiceItem->getFixedRate(); + $amount = $invoiceItem->getAmount(); + } + + if (empty($description)) { + $description = $invoiceItem->getActivity()->getName(); + } + + $user = $invoiceItem->getUser(); + + // this should never happen! + if (empty($appliedRate)) { + $appliedRate = 0; + } + + $activity = $invoiceItem->getActivity(); + $project = $invoiceItem->getProject(); + $customer = $project->getCustomer(); + $currency = $customer->getCurrency(); + + $begin = $invoiceItem->getBegin(); + $end = $invoiceItem->getEnd(); + + $values = [ + 'entry.row' => '', + 'entry.description' => $description, + 'entry.amount' => $amount, + 'entry.type' => $invoiceItem->getType(), + 'entry.category' => $invoiceItem->getCategory(), + 'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency), + 'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, null), + 'entry.rate_plain' => $appliedRate, + 'entry.total' => $formatter->getFormattedMoney($rate, $currency), + 'entry.total_nc' => $formatter->getFormattedMoney($rate, null), + 'entry.total_plain' => $rate, + 'entry.currency' => $currency, + 'entry.duration' => $invoiceItem->getDuration(), + 'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($invoiceItem->getDuration()), + 'entry.duration_minutes' => number_format($invoiceItem->getDuration() / 60), + 'entry.begin' => $formatter->getFormattedDateTime($begin), + 'entry.begin_time' => $formatter->getFormattedTime($begin), + 'entry.begin_timestamp' => $begin->getTimestamp(), + 'entry.end' => $formatter->getFormattedDateTime($end), + 'entry.end_time' => $formatter->getFormattedTime($end), + 'entry.end_timestamp' => $end->getTimestamp(), + 'entry.date' => $formatter->getFormattedDateTime($begin), + 'entry.user_id' => $user->getId(), + 'entry.user_name' => $user->getUsername(), + 'entry.user_title' => $user->getTitle(), + 'entry.user_alias' => $user->getAlias(), + 'entry.activity' => $activity->getName(), + 'entry.activity_id' => $activity->getId(), + 'entry.project' => $project->getName(), + 'entry.project_id' => $project->getId(), + 'entry.customer' => $customer->getName(), + 'entry.customer_id' => $customer->getId(), + ]; + + foreach ($invoiceItem->getAdditionalFields() as $name => $value) { + $values = array_merge($values, [ + 'entry.meta.' . $name => $value, + ]); + } + + return $values; + } } diff --git a/src/Invoice/Renderer/AbstractRenderer.php b/src/Invoice/Renderer/AbstractRenderer.php index 412d18670..1b9974cbc 100644 --- a/src/Invoice/Renderer/AbstractRenderer.php +++ b/src/Invoice/Renderer/AbstractRenderer.php @@ -9,93 +9,54 @@ namespace App\Invoice\Renderer; -use App\Twig\DateExtensions; -use App\Twig\Extensions; -use Symfony\Contracts\Translation\TranslatorInterface; +use App\Entity\InvoiceDocument; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; +/** + * @internal + */ abstract class AbstractRenderer { - use RendererTrait; - /** - * @var DateExtensions + * @return string[] */ - protected $dateExtension; + abstract protected function getFileExtensions(); /** - * @var Extensions - */ - protected $extension; - - /** - * @var TranslatorInterface - */ - protected $translator; - - /** - * @param TranslatorInterface $translator - * @param DateExtensions $dateExtension - * @param Extensions $extensions - */ - public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions) - { - $this->translator = $translator; - $this->dateExtension = $dateExtension; - $this->extension = $extensions; - } - - /** - * @param \DateTime $date - * @return mixed - */ - protected function getFormattedDateTime(\DateTime $date) - { - return $this->dateExtension->dateShort($date); - } - - /** - * @param \DateTime $date - * @return mixed - */ - protected function getFormattedTime(\DateTime $date) - { - return $this->dateExtension->time($date); - } - - /** - * @param int $amount - * @param string $currency * @return string */ - protected function getFormattedMoney($amount, $currency) + abstract protected function getContentType(); + + /** + * @param InvoiceDocument $document + * @return bool + */ + public function supports(InvoiceDocument $document): bool { - return $this->extension->money($amount, $currency); + foreach ($this->getFileExtensions() as $extension) { + if (stripos($document->getFilename(), $extension) !== false) { + return true; + } + } + + return false; } /** - * @param \DateTime $date - * @return mixed + * @param mixed $file + * @param string $filename + * @return BinaryFileResponse */ - protected function getFormattedMonthName(\DateTime $date) + protected function getFileResponse($file, $filename) { - return $this->translator->trans($this->dateExtension->monthName($date)); - } + $response = new BinaryFileResponse($file); + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); - /** - * @param int $seconds - * @return mixed - */ - protected function getFormattedDuration($seconds) - { - return $this->extension->duration($seconds); - } + $response->headers->set('Content-Type', $this->getContentType()); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(true); - /** - * @param int $seconds - * @return mixed - */ - protected function getFormattedDecimalDuration($seconds) - { - return $this->extension->durationDecimal($seconds); + return $response; } } diff --git a/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php b/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php index b6690d53d..f9a1570b0 100644 --- a/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php +++ b/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php @@ -16,6 +16,9 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Symfony\Component\HttpFoundation\Response; +/** + * @internal + */ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer { /** @@ -40,7 +43,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer $spreadsheet = IOFactory::load($document->getFilename()); $worksheet = $spreadsheet->getActiveSheet(); $entries = $model->getCalculator()->getEntries(); - $replacer = $this->modelToReplacer($model); + $sheetReplacer = $model->toArray(); $invoiceItemCount = count($entries); if ($invoiceItemCount > 1) { $this->addTemplateRows($worksheet, $invoiceItemCount); @@ -57,26 +60,37 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer $entryRow = 0; foreach ($worksheet->getRowIterator() as $row) { - $invoiceItem = $entries[$entryRow]; $sheetValues = false; foreach ($row->getCellIterator() as $cell) { $value = $cell->getValue(); - if (stripos($value, '${entry.') !== false) { - if ($sheetValues === false) { - $sheetValues = $this->invoiceItemToArray($invoiceItem); - } - $searcher = str_replace('${', '', $value); - $searcher = str_replace('}', '', $searcher); - if (isset($sheetValues[$searcher])) { - $cell->setValue($sheetValues[$searcher]); - } - } elseif (stripos($value, '${') !== false) { - $searcher = str_replace('${', '', $value); - $searcher = str_replace('}', '', $searcher); - if (isset($replacer[$searcher])) { - $cell->setValue($replacer[$searcher]); - } + $replacer = null; + if (stripos($value, '${') === false) { + continue; } + + if (stripos($value, '${entry.') !== false) { + if ($sheetValues === false && isset($entries[$entryRow])) { + $sheetValues = $model->itemToArray($entries[$entryRow]); + } + $replacer = $sheetValues; + } elseif (stripos($value, '${') !== false) { + $replacer = $sheetReplacer; + } + + if (empty($replacer)) { + continue; + } + + // we can have mixed cell content, which makes it much more complicated + foreach ($replacer as $key => $content) { + $searchKey = '${' . $key . '}'; + if (stripos($value, $searchKey) === false) { + continue; + } + $value = str_replace($searchKey, $content, $value); + } + + $cell->setValue($value); } if ($sheetValues !== false && $entryRow < $invoiceItemCount - 1) { @@ -105,7 +119,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer $value = $cell->getValue(); if (stripos($value, '${entry.') !== false) { $startRow = $row->getRowIndex(); - $worksheet->insertNewRowBefore($row->getRowIndex(), $invoiceItemCount - 1); + $worksheet->insertNewRowBefore($startRow + 1, $invoiceItemCount - 1); break 2; } @@ -123,15 +137,18 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer throw new \Exception('Invalid invoice document, no template row found.'); } - // fill up all new rows with template values - $templateRow = $invoiceItemCount + $startRow; - $iterator = $worksheet->getRowIterator($templateRow - 1, $templateRow); + // fill up all new rows with template replacer + $templateRow = $startRow; + $iterator = $worksheet->getRowIterator($templateRow, $templateRow + 1); + $templateColumns = []; - foreach ($iterator->current()->getCellIterator() as $cell) { + + $tmpRow = $iterator->current(); + foreach ($tmpRow->getCellIterator() as $cell) { $templateColumns[$cell->getColumn()] = $cell->getValue(); } - $iterator = $worksheet->getRowIterator($startRow, $templateRow - 2); + $iterator = $worksheet->getRowIterator($startRow, $startRow + $invoiceItemCount - 1); foreach ($iterator as $row) { foreach ($row->getCellIterator() as $cell) { $cell->setValue($templateColumns[$cell->getColumn()]); diff --git a/src/Invoice/Renderer/CsvRenderer.php b/src/Invoice/Renderer/CsvRenderer.php index cc4a7d6eb..0e703d17c 100644 --- a/src/Invoice/Renderer/CsvRenderer.php +++ b/src/Invoice/Renderer/CsvRenderer.php @@ -13,7 +13,7 @@ use App\Invoice\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string[] diff --git a/src/Invoice/Renderer/DocxRenderer.php b/src/Invoice/Renderer/DocxRenderer.php index b219fdf33..07779d6fb 100644 --- a/src/Invoice/Renderer/DocxRenderer.php +++ b/src/Invoice/Renderer/DocxRenderer.php @@ -19,7 +19,7 @@ use PhpOffice\PhpWord\TemplateProcessor; use Symfony\Component\HttpFoundation\File\Stream; use Symfony\Component\HttpFoundation\Response; -class DocxRenderer extends AbstractRenderer implements RendererInterface +final class DocxRenderer extends AbstractRenderer implements RendererInterface { /** * @param InvoiceDocument $document @@ -35,7 +35,7 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface $xmlEscaper = new Xml(); $template = new TemplateProcessor($document->getFilename()); - foreach ($this->modelToReplacer($model) as $search => $replace) { + foreach ($model->toArray() as $search => $replace) { $replace = $xmlEscaper->escape($replace); $replace = str_replace(PHP_EOL, '', $replace); @@ -45,12 +45,18 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface try { $template->cloneRow('entry.description', count($model->getCalculator()->getEntries())); } catch (OfficeException $ex) { - $template->cloneRow('entry.row', count($model->getCalculator()->getEntries())); + try { + $template->cloneRow('entry.row', count($model->getCalculator()->getEntries())); + } catch (OfficeException $ex) { + @trigger_error( + sprintf('Invoice document (%s) did not contain a clone row, was that on purpose?', $document->getFilename()) + ); + } } $i = 1; foreach ($model->getCalculator()->getEntries() as $entry) { - $values = $this->invoiceItemToArray($entry); + $values = $model->itemToArray($entry); foreach ($values as $search => $replace) { $replace = $xmlEscaper->escape($replace); $replace = str_replace(PHP_EOL, '', $replace); diff --git a/src/Invoice/Renderer/OdsRenderer.php b/src/Invoice/Renderer/OdsRenderer.php index 3dde4dbdc..8b145fdbb 100644 --- a/src/Invoice/Renderer/OdsRenderer.php +++ b/src/Invoice/Renderer/OdsRenderer.php @@ -13,7 +13,7 @@ use App\Invoice\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string[] diff --git a/src/Invoice/Renderer/RendererTrait.php b/src/Invoice/Renderer/RendererTrait.php index 38722ba8e..91cd01794 100644 --- a/src/Invoice/Renderer/RendererTrait.php +++ b/src/Invoice/Renderer/RendererTrait.php @@ -9,181 +9,28 @@ namespace App\Invoice\Renderer; -use App\Entity\InvoiceDocument; use App\Invoice\InvoiceItem; use App\Invoice\InvoiceModel; -use Symfony\Component\HttpFoundation\BinaryFileResponse; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; trait RendererTrait { /** - * @return string[] + * @var InvoiceModel */ - abstract protected function getFileExtensions(); - - /** - * @return string - */ - abstract protected function getContentType(); - - /** - * @param InvoiceDocument $document - * @return bool - */ - public function supports(InvoiceDocument $document): bool - { - foreach ($this->getFileExtensions() as $extension) { - if (stripos($document->getFilename(), $extension) !== false) { - return true; - } - } - - return false; - } - - /** - * @param \DateTime $date - * @return mixed - */ - abstract protected function getFormattedDateTime(\DateTime $date); - - /** - * @param \DateTime $date - * @return mixed - */ - abstract protected function getFormattedTime(\DateTime $date); - - /** - * @param int $amount - * @param string|null $currency - * @return mixed - */ - abstract protected function getFormattedMoney($amount, $currency); - - /** - * @param \DateTime $date - * @return mixed - */ - abstract protected function getFormattedMonthName(\DateTime $date); - - /** - * @param int $seconds - * @return mixed - */ - abstract protected function getFormattedDuration($seconds); - - /** - * @param int $seconds - * @return mixed - */ - abstract protected function getFormattedDecimalDuration($seconds); + private $model; /** + * @deprecated since 1.6.2 - will be removed with 2.0 * @param InvoiceModel $model * @return array */ protected function modelToReplacer(InvoiceModel $model) { - $customer = $model->getCustomer(); - $project = $model->getQuery()->getProject(); - $activity = $model->getQuery()->getActivity(); - $currency = $model->getCalculator()->getCurrency(); - $tax = $model->getCalculator()->getTax(); - $total = $model->getCalculator()->getTotal(); - $subtotal = $model->getCalculator()->getSubtotal(); + @trigger_error('modelToReplacer() is deprecated and will be removed with 2.0', E_USER_DEPRECATED); - $values = [ - 'invoice.due_date' => $this->getFormattedDateTime($model->getDueDate()), - 'invoice.date' => $this->getFormattedDateTime($model->getInvoiceDate()), - 'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(), - 'invoice.currency' => $currency, - 'invoice.vat' => $model->getCalculator()->getVat(), - 'invoice.tax' => $this->getFormattedMoney($tax, $currency), - 'invoice.tax_nc' => $this->getFormattedMoney($tax, null), - 'invoice.total_time' => $this->getFormattedDuration($model->getCalculator()->getTimeWorked()), - 'invoice.duration_decimal' => $this->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()), - 'invoice.total' => $this->getFormattedMoney($total, $currency), - 'invoice.total_nc' => $this->getFormattedMoney($total, null), - 'invoice.subtotal' => $this->getFormattedMoney($subtotal, $currency), - 'invoice.subtotal_nc' => $this->getFormattedMoney($subtotal, null), + $this->model = $model; - 'template.name' => $model->getTemplate()->getName(), - 'template.company' => $model->getTemplate()->getCompany(), - 'template.address' => $model->getTemplate()->getAddress(), - 'template.title' => $model->getTemplate()->getTitle(), - 'template.payment_terms' => $model->getTemplate()->getPaymentTerms(), - 'template.due_days' => $model->getTemplate()->getDueDays(), - 'template.vat_id' => $model->getTemplate()->getVatId(), - 'template.contact' => $model->getTemplate()->getContact(), - 'template.payment_details' => $model->getTemplate()->getPaymentDetails(), - - 'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()), - 'query.day' => $model->getQuery()->getBegin()->format('d'), - 'query.end' => $this->getFormattedDateTime($model->getQuery()->getEnd()), - 'query.month' => $this->getFormattedMonthName($model->getQuery()->getBegin()), - 'query.month_number' => $model->getQuery()->getBegin()->format('m'), - 'query.year' => $model->getQuery()->getBegin()->format('Y'), - ]; - - if (null !== $activity) { - $values = array_merge($values, [ - 'activity.id' => $activity->getId(), - 'activity.name' => $activity->getName(), - 'activity.comment' => $activity->getComment(), - 'activity.fixed_rate' => $activity->getFixedRate(), - 'activity.hourly_rate' => $activity->getHourlyRate(), - ]); - - foreach ($activity->getVisibleMetaFields() as $metaField) { - $values = array_merge($values, [ - 'activity.meta.' . $metaField->getName() => $metaField->getValue(), - ]); - } - } - - if (null !== $project) { - $values = array_merge($values, [ - 'project.id' => $project->getId(), - 'project.name' => $project->getName(), - 'project.comment' => $project->getComment(), - 'project.order_number' => $project->getOrderNumber(), - 'project.order_date' => null !== $project->getOrderDate() ? $this->getFormattedDateTime($project->getOrderDate()) : '', - 'project.fixed_rate' => $project->getFixedRate(), - 'project.hourly_rate' => $project->getHourlyRate(), - ]); - - foreach ($project->getVisibleMetaFields() as $metaField) { - $values = array_merge($values, [ - 'project.meta.' . $metaField->getName() => $metaField->getValue(), - ]); - } - } - - if (null !== $customer) { - $values = array_merge($values, [ - 'customer.id' => $customer->getId(), - 'customer.address' => $customer->getAddress(), - 'customer.name' => $customer->getName(), - 'customer.contact' => $customer->getContact(), - 'customer.company' => $customer->getCompany(), - 'customer.vat' => $customer->getVatId(), - 'customer.number' => $customer->getNumber(), - 'customer.country' => $customer->getCountry(), - 'customer.homepage' => $customer->getHomepage(), - 'customer.comment' => $customer->getComment(), - 'customer.fixed_rate' => $customer->getFixedRate(), - 'customer.hourly_rate' => $customer->getHourlyRate(), - ]); - - foreach ($customer->getVisibleMetaFields() as $metaField) { - $values = array_merge($values, [ - 'customer.meta.' . $metaField->getName() => $metaField->getValue(), - ]); - } - } - - return $values; + return $model->toArray(); } /** @@ -193,94 +40,18 @@ trait RendererTrait { @trigger_error('timesheetToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED); - return $this->invoiceItemToArray($invoiceItem); - } - - protected function invoiceItemToArray(InvoiceItem $invoiceItem): array - { - $rate = $invoiceItem->getRate(); - $hourlyRate = $invoiceItem->getHourlyRate(); - $amount = $this->getFormattedDuration($invoiceItem->getDuration()); - $description = $invoiceItem->getDescription(); - - if ($invoiceItem->isFixedRate()) { - $hourlyRate = $invoiceItem->getFixedRate(); - $amount = $invoiceItem->getAmount(); - } - - if (empty($description)) { - $description = $invoiceItem->getActivity()->getName(); - } - - $user = $invoiceItem->getUser(); - - // this should never happen! - if (empty($hourlyRate)) { - $hourlyRate = 0; - } - - $activity = $invoiceItem->getActivity(); - $project = $invoiceItem->getProject(); - $customer = $project->getCustomer(); - $currency = $customer->getCurrency(); - - $begin = $invoiceItem->getBegin(); - $end = $invoiceItem->getEnd(); - - $values = [ - 'entry.row' => '', - 'entry.description' => $description, - 'entry.amount' => $amount, - 'entry.rate' => $this->getFormattedMoney($hourlyRate, $currency), - 'entry.rate_nc' => $this->getFormattedMoney($hourlyRate, null), - 'entry.total' => $this->getFormattedMoney($rate, $currency), - 'entry.total_nc' => $this->getFormattedMoney($rate, null), - 'entry.currency' => $currency, - 'entry.duration' => $invoiceItem->getDuration(), - 'entry.duration_decimal' => $this->getFormattedDecimalDuration($invoiceItem->getDuration()), - 'entry.duration_minutes' => number_format($invoiceItem->getDuration() / 60), - 'entry.begin' => $this->getFormattedDateTime($begin), - 'entry.begin_time' => $this->getFormattedTime($begin), - 'entry.begin_timestamp' => $begin->getTimestamp(), - 'entry.end' => $this->getFormattedDateTime($end), - 'entry.end_time' => $this->getFormattedTime($end), - 'entry.end_timestamp' => $end->getTimestamp(), - 'entry.date' => $this->getFormattedDateTime($begin), - 'entry.user_id' => $user->getId(), - 'entry.user_name' => $user->getUsername(), - 'entry.user_title' => $user->getTitle(), - 'entry.user_alias' => $user->getAlias(), - 'entry.activity' => $activity->getName(), - 'entry.activity_id' => $activity->getId(), - 'entry.project' => $project->getName(), - 'entry.project_id' => $project->getId(), - 'entry.customer' => $customer->getName(), - 'entry.customer_id' => $customer->getId(), - ]; - - foreach ($invoiceItem->getAdditionalFields() as $name => $value) { - $values = array_merge($values, [ - 'entry.meta.' . $name => $value, - ]); - } - - return $values; + return $this->model->itemToArray($invoiceItem); } /** - * @param mixed $file - * @param string $filename - * @return BinaryFileResponse + * @deprecated since 1.6.2 - will be removed with 2.0 + * @param InvoiceItem $invoiceItem + * @return array */ - protected function getFileResponse($file, $filename) + protected function invoiceItemToArray(InvoiceItem $invoiceItem): array { - $response = new BinaryFileResponse($file); - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + @trigger_error('invoiceItemToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED); - $response->headers->set('Content-Type', $this->getContentType()); - $response->headers->set('Content-Disposition', $disposition); - $response->deleteFileAfterSend(true); - - return $response; + return $this->model->itemToArray($invoiceItem); } } diff --git a/src/Invoice/Renderer/XlsxRenderer.php b/src/Invoice/Renderer/XlsxRenderer.php index be2cc8c4a..9a0bd84e1 100644 --- a/src/Invoice/Renderer/XlsxRenderer.php +++ b/src/Invoice/Renderer/XlsxRenderer.php @@ -13,7 +13,7 @@ use App\Invoice\RendererInterface; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Spreadsheet; -class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface { /** * @return string[] diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index 8f5c8a1de..f250d078e 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -346,6 +346,18 @@ class ActivityRepository extends EntityRepository return $qb; } + public function countActivitiesForQuery(ActivityQuery $query): int + { + $qb = $this->getQueryBuilderForQuery($query); + $qb + ->resetDQLPart('select') + ->resetDQLPart('orderBy') + ->select($qb->expr()->countDistinct('a.id')) + ; + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + public function getPagerfantaForQuery(ActivityQuery $query): Pagerfanta { $paginator = new Pagerfanta($this->getPaginatorForQuery($query)); @@ -357,14 +369,7 @@ class ActivityRepository extends EntityRepository protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface { - $qb = $this->getQueryBuilderForQuery($query); - $qb - ->resetDQLPart('select') - ->resetDQLPart('orderBy') - ->select($qb->expr()->countDistinct('a.id')) - ; - $counter = (int) $qb->getQuery()->getSingleScalarResult(); - + $counter = $this->countActivitiesForQuery($query); $qb = $this->getQueryBuilderForQuery($query); return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter); diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index 70d412023..2ccfc0bae 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -272,7 +272,7 @@ class CustomerRepository extends EntityRepository return $paginator; } - protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface + public function countCustomersForQuery(CustomerQuery $query): int { $qb = $this->getQueryBuilderForQuery($query); $qb @@ -280,8 +280,13 @@ class CustomerRepository extends EntityRepository ->resetDQLPart('orderBy') ->select($qb->expr()->countDistinct('c.id')) ; - $counter = (int) $qb->getQuery()->getSingleScalarResult(); + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface + { + $counter = $this->countCustomersForQuery($query); $qb = $this->getQueryBuilderForQuery($query); return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter); diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 40f3dc8ee..c2e1b1917 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -283,6 +283,18 @@ class ProjectRepository extends EntityRepository return $qb; } + public function countProjectsForQuery(ProjectQuery $query): int + { + $qb = $this->getQueryBuilderForQuery($query); + $qb + ->resetDQLPart('select') + ->resetDQLPart('orderBy') + ->select($qb->expr()->countDistinct('p.id')) + ; + + return (int) $qb->getQuery()->getSingleScalarResult(); + } + public function getPagerfantaForQuery(ProjectQuery $query): Pagerfanta { $paginator = new Pagerfanta($this->getPaginatorForQuery($query)); @@ -294,14 +306,7 @@ class ProjectRepository extends EntityRepository private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface { - $qb = $this->getQueryBuilderForQuery($query); - $qb - ->resetDQLPart('select') - ->resetDQLPart('orderBy') - ->select($qb->expr()->countDistinct('p.id')) - ; - $counter = (int) $qb->getQuery()->getSingleScalarResult(); - + $counter = $this->countProjectsForQuery($query); $qb = $this->getQueryBuilderForQuery($query); return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter); diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index 9a811207b..d5d0ce98d 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -640,6 +640,10 @@ class TimesheetRepository extends EntityRepository $currentUser = $query->getCurrentUser(); if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) { + // make sure that the user himself is in the list of users, if he is part of a team + // if teams are used and the user is not a teamlead, the list of users would be empty and then leading to NOT limit the select by user IDs + $user[] = $currentUser; + foreach ($currentUser->getTeams() as $team) { if ($currentUser->isTeamleadOf($team)) { $query->addTeam($team); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 0e413220b..a83db1b7d 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -236,7 +236,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface return $paginator; } - protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface + public function countUsersForQuery(UserQuery $query): int { $qb = $this->getQueryBuilderForQuery($query); $qb @@ -244,8 +244,13 @@ class UserRepository extends EntityRepository implements UserLoaderInterface ->resetDQLPart('orderBy') ->select($qb->expr()->countDistinct('u.id')) ; - $counter = (int) $qb->getQuery()->getSingleScalarResult(); + return (int) $qb->getQuery()->getSingleScalarResult(); + } + + protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface + { + $counter = $this->countUsersForQuery($query); $qb = $this->getQueryBuilderForQuery($query); return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter); diff --git a/src/Validator/Constraints/TimesheetValidator.php b/src/Validator/Constraints/TimesheetValidator.php index 6b0e064ee..dc008c959 100644 --- a/src/Validator/Constraints/TimesheetValidator.php +++ b/src/Validator/Constraints/TimesheetValidator.php @@ -119,12 +119,16 @@ class TimesheetValidator extends ConstraintValidator ->addViolation(); } - if (false === $this->configuration->isAllowFutureTimes() && time() < $timesheet->getBegin()->getTimestamp()) { - $context->buildViolation('The begin date cannot be in the future.') - ->atPath('begin') - ->setTranslationDomain('validators') - ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR) - ->addViolation(); + if (false === $this->configuration->isAllowFutureTimes()) { + // allow configured default rounding time + 1 minute - see #1295 + $allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60; + if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) { + $context->buildViolation('The begin date cannot be in the future.') + ->atPath('begin') + ->setTranslationDomain('validators') + ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR) + ->addViolation(); + } } } diff --git a/templates/invoice/index.html.twig b/templates/invoice/index.html.twig index 5071b0d10..5641d53ac 100644 --- a/templates/invoice/index.html.twig +++ b/templates/invoice/index.html.twig @@ -37,7 +37,9 @@ {{ form_row(form.customer) }} {{ form_row(form.project) }} {{ form_row(form.activity) }} - {{ form_row(form.users) }} + {% if form.users is defined %} + {{ form_row(form.users) }} + {% endif %} {{ form_row(form.tags) }} {{ form_row(form.exported) }} {{ form_row(form.template) }} diff --git a/templates/invoice/renderer/company.docx b/templates/invoice/renderer/company.docx index d2c076190..50381146e 100644 Binary files a/templates/invoice/renderer/company.docx and b/templates/invoice/renderer/company.docx differ diff --git a/templates/invoice/renderer/default.html.twig b/templates/invoice/renderer/default.html.twig index 253c07dfc..30594cff3 100644 --- a/templates/invoice/renderer/default.html.twig +++ b/templates/invoice/renderer/default.html.twig @@ -110,7 +110,7 @@ {{ model.calculator.tax|money(model.calculator.currency) }} - + {{ 'invoice.total'|trans }} @@ -134,7 +134,7 @@