From fa1c79e15cc4027eda713c10717f0bb51c3d6a9c Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Fri, 8 Nov 2019 16:10:38 +0100 Subject: [PATCH] support different formats in user timesheet exports (#1222) --- .travis.yml | 2 +- config/services.yaml | 3 - src/Controller/ProfileController.php | 17 +- .../TimesheetAbstractController.php | 23 +- src/Controller/TimesheetController.php | 14 +- src/Controller/TimesheetTeamController.php | 12 +- .../Compiler/ExportServiceCompilerPass.php | 46 ++ .../Base/AbstractSpreadsheetRenderer.php | 407 ++++++++++++++++++ src/Export/Base/CsvRenderer.php | 61 +++ src/Export/Base/HtmlRenderer.php | 95 ++++ src/Export/Base/PDFRenderer.php | 82 ++++ src/Export/Base/RendererTrait.php | 63 +++ src/Export/Base/XlsxRenderer.php | 52 +++ .../Renderer/AbstractSpreadsheetRenderer.php | 360 +--------------- src/Export/Renderer/CsvRenderer.php | 59 +-- src/Export/Renderer/HtmlRenderer.php | 93 +--- src/Export/Renderer/PDFRenderer.php | 80 +--- src/Export/Renderer/RendererTrait.php | 53 +-- src/Export/Renderer/XlsxRenderer.php | 53 +-- src/Export/ServiceExport.php | 39 +- src/Export/Timesheet/CsvRenderer.php | 26 ++ src/Export/Timesheet/HtmlRenderer.php | 75 ++++ src/Export/Timesheet/PDFRenderer.php | 17 + src/Export/Timesheet/XlsxRenderer.php | 22 + src/Export/TimesheetExportInterface.php | 29 ++ src/Kernel.php | 13 + src/Twig/TimesheetExtension.php | 47 ++ templates/export/renderer/pdf.html.twig | 53 ++- templates/macros/widgets.html.twig | 17 +- templates/timesheet-team/actions.html.twig | 11 +- templates/timesheet-team/export.html.twig | 91 ---- templates/timesheet/actions.html.twig | 11 +- templates/timesheet/export.html.twig | 31 +- tests/Command/InstallCommandTest.php | 2 +- tests/Command/PluginCommandTest.php | 4 +- tests/Controller/ControllerBaseTest.php | 2 +- tests/Controller/TimesheetControllerTest.php | 7 +- .../TimesheetTeamControllerTest.php | 7 +- .../SqliteSessionInitSubscriberTest.php | 14 +- .../RegistrationSubscriberTest.php | 2 +- .../Export/Renderer/AbstractRendererTest.php | 12 +- tests/Export/Renderer/CsvRendererTest.php | 3 + tests/Export/Renderer/HtmlRendererTest.php | 4 +- tests/Export/Renderer/PdfRendererTest.php | 6 +- tests/Export/Renderer/XlsxRendererTest.php | 3 + tests/Export/ServiceExportTest.php | 45 +- .../Export/Timesheet/AbstractRendererTest.php | 221 ++++++++++ tests/Export/Timesheet/CsvRendererTest.php | 107 +++++ tests/Export/Timesheet/HtmlRendererTest.php | 51 +++ tests/Export/Timesheet/PdfRendererTest.php | 65 +++ tests/Export/Timesheet/XlsxRendererTest.php | 53 +++ .../TagArrayToStringTransformerTest.php | 2 +- .../Calculator/AbstractCalculatorTest.php | 6 +- .../ActivityInvoiceCalculatorTest.php | 8 +- .../Calculator/DateInvoiceCalculatorTest.php | 8 +- .../ProjectInvoiceCalculatorTest.php | 8 +- .../Calculator/UserInvoiceCalculatorTest.php | 6 +- tests/Invoice/Renderer/RendererTestTrait.php | 6 +- tests/Ldap/LdapAuthenticationProviderTest.php | 22 +- tests/Ldap/LdapDriverTest.php | 8 +- tests/Ldap/LdapManagerTest.php | 22 +- tests/Ldap/LdapUserProviderTest.php | 6 +- tests/Mocks/Security/CurrentUserFactory.php | 4 +- tests/Plugin/PluginManagerTest.php | 2 +- .../Paginator/QueryBuilderPaginatorTest.php | 2 +- tests/Repository/Query/BaseQueryTest.php | 4 +- tests/Repository/WidgetRepositoryTest.php | 10 +- tests/Twig/MarkdownExtensionTest.php | 6 +- tests/Twig/WidgetExtensionTest.php | 2 +- .../Constraints/TimesheetValidatorTest.php | 2 +- .../Renderer/CompoundChartRendererTest.php | 4 +- .../Renderer/CompoundRowRendererTest.php | 4 +- .../Renderer/SimpleWidgetRendererTest.php | 4 +- .../Widget/Type/DailyWorkingTimeChartTest.php | 4 +- tests/Widget/WidgetServiceTest.php | 8 +- 75 files changed, 1872 insertions(+), 951 deletions(-) create mode 100644 src/DependencyInjection/Compiler/ExportServiceCompilerPass.php create mode 100644 src/Export/Base/AbstractSpreadsheetRenderer.php create mode 100644 src/Export/Base/CsvRenderer.php create mode 100644 src/Export/Base/HtmlRenderer.php create mode 100644 src/Export/Base/PDFRenderer.php create mode 100644 src/Export/Base/RendererTrait.php create mode 100644 src/Export/Base/XlsxRenderer.php create mode 100644 src/Export/Timesheet/CsvRenderer.php create mode 100644 src/Export/Timesheet/HtmlRenderer.php create mode 100644 src/Export/Timesheet/PDFRenderer.php create mode 100644 src/Export/Timesheet/XlsxRenderer.php create mode 100644 src/Export/TimesheetExportInterface.php create mode 100644 src/Twig/TimesheetExtension.php delete mode 100644 templates/timesheet-team/export.html.twig create mode 100644 tests/Export/Timesheet/AbstractRendererTest.php create mode 100644 tests/Export/Timesheet/CsvRendererTest.php create mode 100644 tests/Export/Timesheet/HtmlRendererTest.php create mode 100644 tests/Export/Timesheet/PdfRendererTest.php create mode 100644 tests/Export/Timesheet/XlsxRendererTest.php diff --git a/.travis.yml b/.travis.yml index 10e3de15..243d8457 100644 --- a/.travis.yml +++ b/.travis.yml @@ -68,7 +68,7 @@ install: - composer require zendframework/zend-ldap script: - - if [[ $CODECOVERAGE == 1 ]]; then vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi; + - if [[ $CODECOVERAGE == 1 ]]; then TEST_WITH_BUNDLES=1 vendor/bin/phpunit tests/ --coverage-clover=coverage.xml; fi; - if [[ $CODECOVERAGE == 0 ]]; then vendor/bin/phpunit tests/; fi; - if [[ $DB == 'sqlite' ]]; then cp tests/.env.dist.sqlite .env; fi; - if [[ $DB == "mysql" || $DB == "mariadb" ]]; then cp tests/.env.dist.mysql .env; fi; diff --git a/config/services.yaml b/config/services.yaml index 4ca860c0..4a8dbd71 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -74,9 +74,6 @@ services: App\Plugin\PluginManager: arguments: [!tagged kimai.plugin] - App\Export\ServiceExport: - arguments: [!tagged export.renderer] - App\Widget\WidgetService: arguments: $renderer: !tagged widget.renderer diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index 956045c7..59771588 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -17,6 +17,7 @@ use App\Form\UserPasswordType; use App\Form\UserPreferencesForm; use App\Form\UserRolesType; use App\Form\UserTeamsType; +use App\Repository\TeamRepository; use App\Repository\TimesheetRepository; use App\Voter\UserVoter; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; @@ -38,21 +39,21 @@ class ProfileController extends AbstractController /** * @var EventDispatcherInterface */ - protected $dispatcher; - + private $dispatcher; /** * @var UserPasswordEncoderInterface */ - protected $encoder; - + private $encoder; /** - * @param UserPasswordEncoderInterface $encoder - * @param EventDispatcherInterface $dispatcher + * @var TeamRepository */ - public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher) + private $teams; + + public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, TeamRepository $teams) { $this->encoder = $encoder; $this->dispatcher = $dispatcher; + $this->teams = $teams; } /** @@ -283,7 +284,7 @@ class ProfileController extends AbstractController $apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user); $forms['api-token'] = $apiTokenForm->createView(); } - if ($this->isGranted(UserVoter::TEAMS, $user)) { + if ($this->isGranted(UserVoter::TEAMS, $user) && $this->teams->count([]) > 0) { $teamsForm = $teamsForm ?: $this->createTeamsForm($user); $forms['teams'] = $teamsForm->createView(); } diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index ff03bc97..6ff1e6eb 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -15,6 +15,7 @@ use App\Entity\Tag; use App\Entity\Timesheet; use App\Event\TimesheetMetaDefinitionEvent; use App\Event\TimesheetMetaDisplayEvent; +use App\Export\ServiceExport; use App\Form\TimesheetEditForm; use App\Form\Toolbar\TimesheetToolbarForm; use App\Repository\ActivityRepository; @@ -53,19 +54,25 @@ abstract class TimesheetAbstractController extends AbstractController * @var EventDispatcherInterface */ protected $dispatcher; + /** + * @var ServiceExport + */ + protected $exportService; public function __construct( UserDateTimeFactory $dateTime, TimesheetConfiguration $configuration, TimesheetRepository $repository, TrackingModeService $service, - EventDispatcherInterface $dispatcher + EventDispatcherInterface $dispatcher, + ServiceExport $exportService ) { $this->dateTime = $dateTime; $this->configuration = $configuration; $this->repository = $repository; $this->trackingModeService = $service; $this->dispatcher = $dispatcher; + $this->exportService = $exportService; } protected function getTrackingMode(): TrackingModeInterface @@ -227,7 +234,7 @@ abstract class TimesheetAbstractController extends AbstractController ]); } - protected function export(Request $request, string $renderTemplate, string $location): Response + protected function export(Request $request, string $exporterId): Response { $query = new TimesheetQuery(); @@ -252,11 +259,13 @@ abstract class TimesheetAbstractController extends AbstractController $entries = $this->getRepository()->getTimesheetsForQuery($query); - return $this->render($renderTemplate, [ - 'entries' => $entries, - 'query' => $query, - 'metaColumns' => $this->findMetaColumns($query, $location), - ]); + $exporter = $this->exportService->getTimesheetExporterById($exporterId); + + if (null === $exporter) { + throw $this->createNotFoundException('Invalid timesheet exporter given'); + } + + return $exporter->render($entries, $query); } protected function prepareQuery(TimesheetQuery $query) diff --git a/src/Controller/TimesheetController.php b/src/Controller/TimesheetController.php index 372ae4c7..741dfd93 100644 --- a/src/Controller/TimesheetController.php +++ b/src/Controller/TimesheetController.php @@ -16,6 +16,7 @@ use App\Repository\ProjectRepository; use App\Repository\TagRepository; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -31,7 +32,7 @@ class TimesheetController extends TimesheetAbstractController * * @param int $page * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function indexAction($page, Request $request) { @@ -39,15 +40,16 @@ class TimesheetController extends TimesheetAbstractController } /** - * @Route(path="/export", name="timesheet_export", methods={"GET"}) + * @Route(path="/export/{exporter}", name="timesheet_export", methods={"GET"}) * @Security("is_granted('export_own_timesheet')") * * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @param string $exporter + * @return Response */ - public function exportAction(Request $request) + public function exportAction(Request $request, string $exporter) { - return $this->export($request, 'timesheet/export.html.twig', TimesheetMetaDisplayEvent::TIMESHEET_EXPORT); + return $this->export($request, $exporter); } /** @@ -80,7 +82,7 @@ class TimesheetController extends TimesheetAbstractController /** * Used for the initial page rendering. * - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function activeEntriesAction() { diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php index 89a57f58..1ae443d8 100644 --- a/src/Controller/TimesheetTeamController.php +++ b/src/Controller/TimesheetTeamController.php @@ -18,6 +18,7 @@ use App\Repository\Query\TimesheetQuery; use App\Repository\TagRepository; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; /** @@ -33,7 +34,7 @@ class TimesheetTeamController extends TimesheetAbstractController * * @param int $page * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @return Response */ public function indexAction($page, Request $request) { @@ -41,14 +42,15 @@ class TimesheetTeamController extends TimesheetAbstractController } /** - * @Route(path="/export", name="admin_timesheet_export", methods={"GET"}) + * @Route(path="/export/{exporter}", name="admin_timesheet_export", methods={"GET"}) * * @param Request $request - * @return \Symfony\Component\HttpFoundation\Response + * @param string $exporter + * @return Response */ - public function exportAction(Request $request) + public function exportAction(Request $request, string $exporter) { - return $this->export($request, 'timesheet-team/export.html.twig', TimesheetMetaDisplayEvent::TEAM_TIMESHEET_EXPORT); + return $this->export($request, $exporter); } /** diff --git a/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php b/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php new file mode 100644 index 00000000..b1aa125d --- /dev/null +++ b/src/DependencyInjection/Compiler/ExportServiceCompilerPass.php @@ -0,0 +1,46 @@ +has(ServiceExport::class)) { + return; + } + + $definition = $container->findDefinition(ServiceExport::class); + + $taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER); + foreach ($taggedRenderer as $id => $tags) { + $definition->addMethodCall('addRenderer', [new Reference($id)]); + } + + $taggedExporter = $container->findTaggedServiceIds(Kernel::TAG_TIMESHEET_EXPORTER); + foreach ($taggedExporter as $id => $tags) { + $definition->addMethodCall('addTimesheetExporter', [new Reference($id)]); + } + } +} diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php new file mode 100644 index 00000000..e3246dd9 --- /dev/null +++ b/src/Export/Base/AbstractSpreadsheetRenderer.php @@ -0,0 +1,407 @@ +translator = $translator; + $this->dateExtension = $dateExtension; + $this->dispatcher = $dispatcher; + $this->voter = $voter; + } + + protected function isRenderRate(TimesheetQuery $query): bool + { + return true; + } + + protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date) + { + if (null === $date) { + $sheet->setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT); + } + + protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date) + { + if (null === $date) { + $sheet->setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT); + } + + protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date) + { + if (null === $date) { + $sheet->setCellValueByColumnAndRow($column, $row, ''); + + return; + } + + $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2); + } + + protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate) + { + $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate)); + $style = $sheet->getStyleByColumnAndRow($column, $row); + $style->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); + } + + protected function setDuration(Worksheet $sheet, $column, $row, $duration) + { + if (null === $duration) { + $duration = 0; + } + $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration)); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); + } + + protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate) + { + $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate)); + } + + protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency) + { + $sheet->setCellValueByColumnAndRow($column, $row, $rate); + $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode( + sprintf(self::RATE_FORMAT_LEFT, $currency) + ); + } + + /** + * @param MetaDisplayEventInterface $event + * @return MetaTableTypeInterface[] + */ + protected function findMetaColumns(MetaDisplayEventInterface $event): array + { + $this->dispatcher->dispatch($event); + + return $event->getFields(); + } + + /** + * @param Timesheet[] $timesheets + * @param TimesheetQuery $query + * @return Spreadsheet + * @throws \PhpOffice\PhpSpreadsheet\Exception + */ + protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet + { + $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); + $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); + $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); + $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); + + $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); + $this->dispatcher->dispatch($event); + $userPreferences = $event->getPreferences(); + + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + $showRates = $this->isRenderRate($query); + $recordsHeaderColumn = 1; + $recordsHeaderRow = 1; + + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration')); + if ($showRates) { + $sheet->setCellValueByColumnAndRow( + $recordsHeaderColumn++, + $recordsHeaderRow, + $this->translator->trans('label.rate') + ); + } + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported')); + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags')); + if ($showRates) { + $sheet->setCellValueByColumnAndRow( + $recordsHeaderColumn++, + $recordsHeaderRow, + $this->translator->trans('label.hourlyRate') + ); + $sheet->setCellValueByColumnAndRow( + $recordsHeaderColumn++, + $recordsHeaderRow, + $this->translator->trans('label.fixedRate') + ); + } + foreach ($timesheetMetaFields as $metaField) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); + } + foreach ($customerMetaFields as $metaField) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); + } + foreach ($projectMetaFields as $metaField) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); + } + foreach ($activityMetaFields as $metaField) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); + } + foreach ($userPreferences as $preference) { + $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($preference->getLabel())); + } + + $entryHeaderRow = $recordsHeaderRow + 1; + + $durationColumn = null; + $rateColumn = null; + + foreach ($timesheets as $timesheet) { + $entryHeaderColumn = 1; + + $customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency(); + $exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported'; + + $this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin()); + $entryHeaderColumn++; + + $this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin()); + $entryHeaderColumn++; + + $this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd()); + $entryHeaderColumn++; + + $this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration()); + $durationColumn = $entryHeaderColumn; + $entryHeaderColumn++; + + if ($showRates) { + $this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency); + $rateColumn = $entryHeaderColumn; + $entryHeaderColumn++; + } + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getUser()->getDisplayName()); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName()); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName()); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName()); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription()); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported)); + $entryHeaderColumn++; + + $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray())); + $entryHeaderColumn++; + + if ($showRates) { + $this->setRate( + $sheet, + $entryHeaderColumn, + $entryHeaderRow, + $timesheet->getHourlyRate(), + $customerCurrency + ); + $entryHeaderColumn++; + + $this->setRate( + $sheet, + $entryHeaderColumn, + $entryHeaderRow, + $timesheet->getFixedRate(), + $customerCurrency + ); + $entryHeaderColumn++; + } + + foreach ($timesheetMetaFields as $metaField) { + $metaField = $timesheet->getMetaField($metaField->getName()); + $metaFieldValue = ''; + if (null !== $metaField) { + $metaFieldValue = $metaField->getValue(); + } + $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); + } + foreach ($customerMetaFields as $metaField) { + $metaField = $timesheet->getProject()->getCustomer()->getMetaField($metaField->getName()); + $metaFieldValue = ''; + if (null !== $metaField) { + $metaFieldValue = $metaField->getValue(); + } + $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); + } + foreach ($projectMetaFields as $metaField) { + $metaField = $timesheet->getProject()->getMetaField($metaField->getName()); + $metaFieldValue = ''; + if (null !== $metaField) { + $metaFieldValue = $metaField->getValue(); + } + $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); + } + foreach ($activityMetaFields as $metaField) { + $metaField = $timesheet->getActivity()->getMetaField($metaField->getName()); + $metaFieldValue = ''; + if (null !== $metaField) { + $metaFieldValue = $metaField->getValue(); + } + $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); + } + foreach ($userPreferences as $preference) { + $metaField = $timesheet->getUser()->getPreference($preference->getName()); + $metaFieldValue = ''; + if (null !== $metaField) { + $metaFieldValue = $metaField->getValue(); + } + $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); + } + + $entryHeaderRow++; + } + + if (null !== $durationColumn) { + $startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate(); + $endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate(); + $this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); + $style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow); + $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); + $style->getFont()->setBold(true); + } + + if (null !== $rateColumn) { + $startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate(); + $endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate(); + $this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); + $style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow); + $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); + $style->getFont()->setBold(true); + } + + return $spreadsheet; + } + + /** + * @param Timesheet[] $timesheets + * @param TimesheetQuery $query + * @return Response + * @throws \PhpOffice\PhpSpreadsheet\Exception + * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception + */ + public function render(array $timesheets, TimesheetQuery $query): Response + { + $spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query); + $filename = $this->saveSpreadsheet($spreadsheet); + + return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension()); + } + + /** + * @return string + */ + abstract public function getFileExtension(): string; + + /** + * @param mixed $file + * @param string $filename + * @return BinaryFileResponse + */ + protected function getFileResponse($file, $filename): Response + { + $response = new BinaryFileResponse($file); + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + + $response->headers->set('Content-Type', $this->getContentType()); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(true); + + return $response; + } + + /** + * @return string + */ + abstract protected function getContentType(): string; + + /** + * @param Spreadsheet $spreadsheet + * @return string + * @throws \Exception + */ + abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string; +} diff --git a/src/Export/Base/CsvRenderer.php b/src/Export/Base/CsvRenderer.php new file mode 100644 index 00000000..c45f1096 --- /dev/null +++ b/src/Export/Base/CsvRenderer.php @@ -0,0 +1,61 @@ +save($filename); + + return $filename; + } + + public function getId(): string + { + return 'csv'; + } + + protected function setDuration(Worksheet $sheet, $column, $row, $duration) + { + $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration)); + } +} diff --git a/src/Export/Base/HtmlRenderer.php b/src/Export/Base/HtmlRenderer.php new file mode 100644 index 00000000..e229553e --- /dev/null +++ b/src/Export/Base/HtmlRenderer.php @@ -0,0 +1,95 @@ +twig = $twig; + $this->dispatcher = $dispatcher; + } + + /** + * @param MetaDisplayEventInterface $event + * @return MetaTableTypeInterface[] + */ + protected function findMetaColumns(MetaDisplayEventInterface $event): array + { + $this->dispatcher->dispatch($event); + + return $event->getFields(); + } + + /** + * @param Timesheet[] $timesheets + * @param TimesheetQuery $query + * @return Response + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function render(array $timesheets, TimesheetQuery $query): Response + { + $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); + $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); + $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); + $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); + + $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); + $this->dispatcher->dispatch($event); + $userPreferences = $event->getPreferences(); + + $content = $this->twig->render('export/renderer/default.html.twig', [ + 'entries' => $timesheets, + 'query' => $query, + 'summaries' => $this->calculateSummary($timesheets), + 'timesheetMetaFields' => $timesheetMetaFields, + 'customerMetaFields' => $customerMetaFields, + 'projectMetaFields' => $projectMetaFields, + 'activityMetaFields' => $activityMetaFields, + 'userPreferences' => $userPreferences, + ]); + + $response = new Response(); + $response->setContent($content); + + return $response; + } + + public function getId(): string + { + return 'html'; + } +} diff --git a/src/Export/Base/PDFRenderer.php b/src/Export/Base/PDFRenderer.php new file mode 100644 index 00000000..11d5cd30 --- /dev/null +++ b/src/Export/Base/PDFRenderer.php @@ -0,0 +1,82 @@ +twig = $twig; + $this->dateTime = $dateTime; + $this->converter = $converter; + } + + protected function getTemplate(): string + { + return 'export/renderer/pdf.html.twig'; + } + + /** + * @param Timesheet[] $timesheets + * @param TimesheetQuery $query + * @return Response + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function render(array $timesheets, TimesheetQuery $query): Response + { + $content = $this->twig->render($this->getTemplate(), [ + 'entries' => $timesheets, + 'query' => $query, + 'now' => $this->dateTime->createDateTime(), + 'summaries' => $this->calculateSummary($timesheets), + ]); + + $content = $this->converter->convertToPdf($content); + + $response = new Response($content); + + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf'); + + $response->headers->set('Content-Type', 'application/pdf'); + $response->headers->set('Content-Disposition', $disposition); + + return $response; + } + + public function getId(): string + { + return 'pdf'; + } +} diff --git a/src/Export/Base/RendererTrait.php b/src/Export/Base/RendererTrait.php new file mode 100644 index 00000000..858973fc --- /dev/null +++ b/src/Export/Base/RendererTrait.php @@ -0,0 +1,63 @@ +getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId(); + $activityId = $timesheet->getActivity()->getId(); + + if (!isset($summary[$id])) { + $summary[$id] = [ + 'customer' => $timesheet->getProject()->getCustomer()->getName(), + 'project' => $timesheet->getProject()->getName(), + 'activities' => [], + 'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), + 'rate' => 0, + 'duration' => 0, + ]; + } + + if (!isset($summary[$id]['activities'][$activityId])) { + $summary[$id]['activities'][$activityId] = [ + 'activity' => $timesheet->getActivity()->getName(), + 'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), + 'rate' => 0, + 'duration' => 0, + ]; + } + + $duration = $timesheet->getDuration(); + if (null === $duration) { + $duration = 0; + } + + $summary[$id]['rate'] += $timesheet->getRate(); + $summary[$id]['duration'] += $duration; + $summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate(); + $summary[$id]['activities'][$activityId]['duration'] += $duration; + } + + asort($summary); + + return $summary; + } +} diff --git a/src/Export/Base/XlsxRenderer.php b/src/Export/Base/XlsxRenderer.php new file mode 100644 index 00000000..c06db1cb --- /dev/null +++ b/src/Export/Base/XlsxRenderer.php @@ -0,0 +1,52 @@ +save($filename); + + return $filename; + } + + public function getId(): string + { + return 'xlsx'; + } +} diff --git a/src/Export/Renderer/AbstractSpreadsheetRenderer.php b/src/Export/Renderer/AbstractSpreadsheetRenderer.php index 5c83660f..bb161db9 100644 --- a/src/Export/Renderer/AbstractSpreadsheetRenderer.php +++ b/src/Export/Renderer/AbstractSpreadsheetRenderer.php @@ -9,361 +9,11 @@ namespace App\Export\Renderer; -use App\Entity\MetaTableTypeInterface; -use App\Entity\Timesheet; -use App\Event\ActivityMetaDisplayEvent; -use App\Event\CustomerMetaDisplayEvent; -use App\Event\MetaDisplayEventInterface; -use App\Event\ProjectMetaDisplayEvent; -use App\Event\TimesheetMetaDisplayEvent; -use App\Event\UserPreferenceDisplayEvent; -use App\Repository\Query\TimesheetQuery; -use App\Twig\DateExtensions; -use DateTime; -use PhpOffice\PhpSpreadsheet\Shared\Date; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Style\Border; -use PhpOffice\PhpSpreadsheet\Style\NumberFormat; -use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\BinaryFileResponse; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Symfony\Contracts\Translation\TranslatorInterface; +use App\Export\Base\AbstractSpreadsheetRenderer as BaseAbstractSpreadsheetRenderer; -abstract class AbstractSpreadsheetRenderer +/** + * @deprecated since 1.6, will be removed with 2.0 + */ +abstract class AbstractSpreadsheetRenderer extends BaseAbstractSpreadsheetRenderer { - public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm'; - public const TIME_FORMAT = 'hh:mm'; - public const DURATION_FORMAT = '[hh]:mm'; - public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]'; - public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* \(#,##0.00\);_("%1$s"* "-"??_);_(@_)'; - public const RATE_FORMAT = self::RATE_FORMAT_LEFT; - - /** - * @var DateExtensions - */ - protected $dateExtension; - /** - * @var TranslatorInterface - */ - protected $translator; - /** - * @var EventDispatcherInterface - */ - protected $dispatcher; - - public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, EventDispatcherInterface $dispatcher) - { - $this->translator = $translator; - $this->dateExtension = $dateExtension; - $this->dispatcher = $dispatcher; - } - - protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date) - { - if (null === $date) { - $sheet->setCellValueByColumnAndRow($column, $row, ''); - - return; - } - - $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); - $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT); - } - - protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date) - { - if (null === $date) { - $sheet->setCellValueByColumnAndRow($column, $row, ''); - - return; - } - - $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); - $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT); - } - - protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date) - { - if (null === $date) { - $sheet->setCellValueByColumnAndRow($column, $row, ''); - - return; - } - - $sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date)); - $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2); - } - - protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate) - { - $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate)); - $style = $sheet->getStyleByColumnAndRow($column, $row); - $style->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); - } - - protected function setDuration(Worksheet $sheet, $column, $row, $duration) - { - if (null === $duration) { - $duration = 0; - } - $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration)); - $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT); - } - - protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate) - { - $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate)); - } - - protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency) - { - $sheet->setCellValueByColumnAndRow($column, $row, $rate); - $sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode( - sprintf(self::RATE_FORMAT_LEFT, $currency) - ); - } - - /** - * @param MetaDisplayEventInterface $event - * @return MetaTableTypeInterface[] - */ - protected function findMetaColumns(MetaDisplayEventInterface $event): array - { - $this->dispatcher->dispatch($event); - - return $event->getFields(); - } - - /** - * @param Timesheet[] $timesheets - * @param TimesheetQuery $query - * @return Spreadsheet - * @throws \PhpOffice\PhpSpreadsheet\Exception - */ - protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet - { - $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); - $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); - $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); - $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); - - $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); - $this->dispatcher->dispatch($event); - $userPreferences = $event->getPreferences(); - - $spreadsheet = new Spreadsheet(); - $sheet = $spreadsheet->getActiveSheet(); - - $recordsHeaderColumn = 1; - $recordsHeaderRow = 1; - - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate')); - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate')); - foreach ($timesheetMetaFields as $metaField) { - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); - } - foreach ($customerMetaFields as $metaField) { - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); - } - foreach ($projectMetaFields as $metaField) { - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); - } - foreach ($activityMetaFields as $metaField) { - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel())); - } - foreach ($userPreferences as $preference) { - $sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($preference->getLabel())); - } - - $entryHeaderRow = $recordsHeaderRow + 1; - - $durationColumn = null; - $rateColumn = null; - - foreach ($timesheets as $timesheet) { - $entryHeaderColumn = 1; - - $customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency(); - $exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported'; - - $this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin()); - $entryHeaderColumn++; - - $this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin()); - $entryHeaderColumn++; - - $this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd()); - $entryHeaderColumn++; - - $this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration()); - $durationColumn = $entryHeaderColumn; - $entryHeaderColumn++; - - $this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency); - $rateColumn = $entryHeaderColumn; - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->getUsername($timesheet)); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName()); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName()); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName()); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription()); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported)); - $entryHeaderColumn++; - - $sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray())); - $entryHeaderColumn++; - - $this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getHourlyRate(), $customerCurrency); - $entryHeaderColumn++; - - $this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getFixedRate(), $customerCurrency); - $entryHeaderColumn++; - - foreach ($timesheetMetaFields as $metaField) { - $metaField = $timesheet->getMetaField($metaField->getName()); - $metaFieldValue = ''; - if (null !== $metaField) { - $metaFieldValue = $metaField->getValue(); - } - $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); - } - foreach ($customerMetaFields as $metaField) { - $metaField = $timesheet->getProject()->getCustomer()->getMetaField($metaField->getName()); - $metaFieldValue = ''; - if (null !== $metaField) { - $metaFieldValue = $metaField->getValue(); - } - $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); - } - foreach ($projectMetaFields as $metaField) { - $metaField = $timesheet->getProject()->getMetaField($metaField->getName()); - $metaFieldValue = ''; - if (null !== $metaField) { - $metaFieldValue = $metaField->getValue(); - } - $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); - } - foreach ($activityMetaFields as $metaField) { - $metaField = $timesheet->getActivity()->getMetaField($metaField->getName()); - $metaFieldValue = ''; - if (null !== $metaField) { - $metaFieldValue = $metaField->getValue(); - } - $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); - } - foreach ($userPreferences as $preference) { - $metaField = $timesheet->getUser()->getPreference($preference->getName()); - $metaFieldValue = ''; - if (null !== $metaField) { - $metaFieldValue = $metaField->getValue(); - } - $sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue); - } - - $entryHeaderRow++; - } - - if (null !== $durationColumn) { - $startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate(); - $endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate(); - $this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); - $style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow); - $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); - $style->getFont()->setBold(true); - } - - if (null !== $rateColumn) { - $startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate(); - $endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate(); - $this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate); - $style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow); - $style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN); - $style->getFont()->setBold(true); - } - - return $spreadsheet; - } - - protected function getUsername(Timesheet $timesheet): string - { - if (!empty($timesheet->getUser()->getAlias())) { - return $timesheet->getUser()->getAlias(); - } - - return $timesheet->getUser()->getUsername(); - } - - /** - * @param Timesheet[] $timesheets - * @param TimesheetQuery $query - * @return Response - * @throws \PhpOffice\PhpSpreadsheet\Exception - * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception - */ - public function render(array $timesheets, TimesheetQuery $query): Response - { - $spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query); - $filename = $this->saveSpreadsheet($spreadsheet); - - return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension()); - } - - /** - * @return string - */ - abstract public function getFileExtension(): string; - - /** - * @param mixed $file - * @param string $filename - * @return BinaryFileResponse - */ - protected function getFileResponse($file, $filename): Response - { - $response = new BinaryFileResponse($file); - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); - - $response->headers->set('Content-Type', $this->getContentType()); - $response->headers->set('Content-Disposition', $disposition); - $response->deleteFileAfterSend(true); - - return $response; - } - - /** - * @return string - */ - abstract protected function getContentType(): string; - - /** - * @param Spreadsheet $spreadsheet - * @return string - * @throws \Exception - */ - abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string; } diff --git a/src/Export/Renderer/CsvRenderer.php b/src/Export/Renderer/CsvRenderer.php index 7028350f..bfb64087 100644 --- a/src/Export/Renderer/CsvRenderer.php +++ b/src/Export/Renderer/CsvRenderer.php @@ -9,73 +9,18 @@ namespace App\Export\Renderer; +use App\Export\Base\CsvRenderer as BaseCsvRenderer; use App\Export\RendererInterface; -use PhpOffice\PhpSpreadsheet\IOFactory; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class CsvRenderer extends BaseCsvRenderer implements RendererInterface { - /** - * @return string - */ - public function getFileExtension(): string - { - return '.csv'; - } - - /** - * @return string - */ - protected function getContentType(): string - { - return 'text/csv'; - } - - /** - * @param Spreadsheet $spreadsheet - * @return string - * @throws \Exception - */ - protected function saveSpreadsheet(Spreadsheet $spreadsheet): string - { - $filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv'); - if (false === $filename) { - throw new \Exception('Could not open temporary file'); - } - - $writer = IOFactory::createWriter($spreadsheet, 'Csv'); - $writer->save($filename); - - return $filename; - } - - /** - * @return string - */ - public function getId(): string - { - return 'csv'; - } - - /** - * @return string - */ public function getIcon(): string { return 'csv'; } - /** - * @return string - */ public function getTitle(): string { return 'csv'; } - - protected function setDuration(Worksheet $sheet, $column, $row, $duration) - { - $sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration)); - } } diff --git a/src/Export/Renderer/HtmlRenderer.php b/src/Export/Renderer/HtmlRenderer.php index 86d44bd1..7d27f7d7 100644 --- a/src/Export/Renderer/HtmlRenderer.php +++ b/src/Export/Renderer/HtmlRenderer.php @@ -9,105 +9,16 @@ namespace App\Export\Renderer; -use App\Entity\MetaTableTypeInterface; -use App\Entity\Timesheet; -use App\Event\ActivityMetaDisplayEvent; -use App\Event\CustomerMetaDisplayEvent; -use App\Event\MetaDisplayEventInterface; -use App\Event\ProjectMetaDisplayEvent; -use App\Event\TimesheetMetaDisplayEvent; -use App\Event\UserPreferenceDisplayEvent; +use App\Export\Base\HtmlRenderer as BaseHtmlRenderer; use App\Export\RendererInterface; -use App\Repository\Query\TimesheetQuery; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\HttpFoundation\Response; -use Twig\Environment; -final class HtmlRenderer implements RendererInterface +final class HtmlRenderer extends BaseHtmlRenderer implements RendererInterface { - use RendererTrait; - - /** - * @var Environment - */ - protected $twig; - /** - * @var EventDispatcherInterface - */ - protected $dispatcher; - - public function __construct(Environment $twig, EventDispatcherInterface $dispatcher) - { - $this->twig = $twig; - $this->dispatcher = $dispatcher; - } - - /** - * @param MetaDisplayEventInterface $event - * @return MetaTableTypeInterface[] - */ - protected function findMetaColumns(MetaDisplayEventInterface $event): array - { - $this->dispatcher->dispatch($event); - - return $event->getFields(); - } - - /** - * @param Timesheet[] $timesheets - * @param TimesheetQuery $query - * @return Response - * @throws \Twig\Error\LoaderError - * @throws \Twig\Error\RuntimeError - * @throws \Twig\Error\SyntaxError - */ - public function render(array $timesheets, TimesheetQuery $query): Response - { - $timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)); - $customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT)); - $projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT)); - $activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT)); - - $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); - $this->dispatcher->dispatch($event); - $userPreferences = $event->getPreferences(); - - $content = $this->twig->render('export/renderer/default.html.twig', [ - 'entries' => $timesheets, - 'query' => $query, - 'summaries' => $this->calculateSummary($timesheets), - 'timesheetMetaFields' => $timesheetMetaFields, - 'customerMetaFields' => $customerMetaFields, - 'projectMetaFields' => $projectMetaFields, - 'activityMetaFields' => $activityMetaFields, - 'userPreferences' => $userPreferences, - ]); - - $response = new Response(); - $response->setContent($content); - - return $response; - } - - /** - * @return string - */ - public function getId(): string - { - return 'html'; - } - - /** - * @return string - */ public function getIcon(): string { return 'print'; } - /** - * @return string - */ public function getTitle(): string { return 'print'; diff --git a/src/Export/Renderer/PDFRenderer.php b/src/Export/Renderer/PDFRenderer.php index 7a937b9c..17082057 100644 --- a/src/Export/Renderer/PDFRenderer.php +++ b/src/Export/Renderer/PDFRenderer.php @@ -9,92 +9,16 @@ namespace App\Export\Renderer; -use App\Entity\Timesheet; +use App\Export\Base\PDFRenderer as BasePDFRenderer; use App\Export\RendererInterface; -use App\Repository\Query\TimesheetQuery; -use App\Timesheet\UserDateTimeFactory; -use App\Utils\HtmlToPdfConverter; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; -use Twig\Environment; -final class PDFRenderer implements RendererInterface +final class PDFRenderer extends BasePDFRenderer implements RendererInterface { - use RendererTrait; - - /** - * @var Environment - */ - protected $twig; - /** - * @var UserDateTimeFactory - */ - protected $dateTime; - /** - * @var HtmlToPdfConverter - */ - protected $converter; - - /** - * @param Environment $twig - * @param UserDateTimeFactory $dateTime - * @param HtmlToPdfConverter $converter - */ - public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter) - { - $this->twig = $twig; - $this->dateTime = $dateTime; - $this->converter = $converter; - } - - /** - * @param Timesheet[] $timesheets - * @param TimesheetQuery $query - * @return Response - * @throws \Twig\Error\LoaderError - * @throws \Twig\Error\RuntimeError - * @throws \Twig\Error\SyntaxError - */ - public function render(array $timesheets, TimesheetQuery $query): Response - { - $content = $this->twig->render('export/renderer/pdf.html.twig', [ - 'entries' => $timesheets, - 'query' => $query, - 'now' => $this->dateTime->createDateTime(), - 'summaries' => $this->calculateSummary($timesheets), - ]); - - $content = $this->converter->convertToPdf($content); - - $response = new Response($content); - - $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf'); - - $response->headers->set('Content-Type', 'application/pdf'); - $response->headers->set('Content-Disposition', $disposition); - - return $response; - } - - /** - * @return string - */ - public function getId(): string - { - return 'pdf'; - } - - /** - * @return string - */ public function getIcon(): string { return 'pdf'; } - /** - * @return string - */ public function getTitle(): string { return 'pdf'; diff --git a/src/Export/Renderer/RendererTrait.php b/src/Export/Renderer/RendererTrait.php index 6b8bb4f7..56c99092 100644 --- a/src/Export/Renderer/RendererTrait.php +++ b/src/Export/Renderer/RendererTrait.php @@ -9,55 +9,12 @@ namespace App\Export\Renderer; -use App\Entity\Timesheet; +use App\Export\Base\RendererTrait as BaseRendererTrait; +/** + * @deprecated since 1.6, will be removed with 2.0 + */ trait RendererTrait { - /** - * @param Timesheet[] $timesheets - * @return array - */ - protected function calculateSummary(array $timesheets) - { - $summary = []; - - foreach ($timesheets as $timesheet) { - $id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId(); - $activityId = $timesheet->getActivity()->getId(); - - if (!isset($summary[$id])) { - $summary[$id] = [ - 'customer' => $timesheet->getProject()->getCustomer()->getName(), - 'project' => $timesheet->getProject()->getName(), - 'activities' => [], - 'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), - 'rate' => 0, - 'duration' => 0, - ]; - } - - if (!isset($summary[$id]['activities'][$activityId])) { - $summary[$id]['activities'][$activityId] = [ - 'activity' => $timesheet->getActivity()->getName(), - 'currency' => $timesheet->getProject()->getCustomer()->getCurrency(), - 'rate' => 0, - 'duration' => 0, - ]; - } - - $duration = $timesheet->getDuration(); - if (null === $duration) { - $duration = 0; - } - - $summary[$id]['rate'] += $timesheet->getRate(); - $summary[$id]['duration'] += $duration; - $summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate(); - $summary[$id]['activities'][$activityId]['duration'] += $duration; - } - - asort($summary); - - return $summary; - } + use BaseRendererTrait; } diff --git a/src/Export/Renderer/XlsxRenderer.php b/src/Export/Renderer/XlsxRenderer.php index 8bea1312..3f6e4074 100644 --- a/src/Export/Renderer/XlsxRenderer.php +++ b/src/Export/Renderer/XlsxRenderer.php @@ -9,65 +9,16 @@ namespace App\Export\Renderer; +use App\Export\Base\XlsxRenderer as BaseXlsxRenderer; use App\Export\RendererInterface; -use PhpOffice\PhpSpreadsheet\IOFactory; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface +final class XlsxRenderer extends BaseXlsxRenderer implements RendererInterface { - /** - * @return string - */ - public function getFileExtension(): string - { - return '.xlsx'; - } - - /** - * @return string - */ - protected function getContentType(): string - { - return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; - } - - /** - * @param Spreadsheet $spreadsheet - * @return string - * @throws \Exception - */ - protected function saveSpreadsheet(Spreadsheet $spreadsheet): string - { - $filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx'); - if (false === $filename) { - throw new \Exception('Could not open temporary file'); - } - - $writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); - $writer->save($filename); - - return $filename; - } - - /** - * @return string - */ - public function getId(): string - { - return 'xlsx'; - } - - /** - * @return string - */ public function getIcon(): string { return 'xlsx'; } - /** - * @return string - */ public function getTitle(): string { return 'xlsx'; diff --git a/src/Export/ServiceExport.php b/src/Export/ServiceExport.php index 32f5d190..d1df2307 100644 --- a/src/Export/ServiceExport.php +++ b/src/Export/ServiceExport.php @@ -9,22 +9,17 @@ namespace App\Export; -class ServiceExport +final class ServiceExport { /** * @var RendererInterface[] */ - protected $renderer = []; + private $renderer = []; /** - * @param RendererInterface[] $renderer + * @var TimesheetExportInterface[] */ - public function __construct(iterable $renderer) - { - foreach ($renderer as $render) { - $this->addRenderer($render); - } - } + private $exporter = []; public function addRenderer(RendererInterface $renderer): ServiceExport { @@ -51,4 +46,30 @@ class ServiceExport return null; } + + public function addTimesheetExporter(TimesheetExportInterface $exporter): ServiceExport + { + $this->exporter[] = $exporter; + + return $this; + } + + /** + * @return TimesheetExportInterface[] + */ + public function getTimesheetExporter(): array + { + return $this->exporter; + } + + public function getTimesheetExporterById(string $id): ?TimesheetExportInterface + { + foreach ($this->exporter as $exporter) { + if ($exporter->getId() === $id) { + return $exporter; + } + } + + return null; + } } diff --git a/src/Export/Timesheet/CsvRenderer.php b/src/Export/Timesheet/CsvRenderer.php new file mode 100644 index 00000000..7644f1e2 --- /dev/null +++ b/src/Export/Timesheet/CsvRenderer.php @@ -0,0 +1,26 @@ +getUser()) { + return $this->voter->isGranted('view_rate_own_timesheet'); + } + + return true; + } +} diff --git a/src/Export/Timesheet/HtmlRenderer.php b/src/Export/Timesheet/HtmlRenderer.php new file mode 100644 index 00000000..e74dd6e8 --- /dev/null +++ b/src/Export/Timesheet/HtmlRenderer.php @@ -0,0 +1,75 @@ +twig = $twig; + $this->dispatcher = $dispatcher; + } + + /** + * @param Timesheet[] $timesheets + * @param TimesheetQuery $query + * @return Response + * @throws \Twig\Error\LoaderError + * @throws \Twig\Error\RuntimeError + * @throws \Twig\Error\SyntaxError + */ + public function render(array $timesheets, TimesheetQuery $query): Response + { + $metaLocation = TimesheetMetaDisplayEvent::TEAM_TIMESHEET_EXPORT; + if (null !== $query->getUser()) { + $metaLocation = TimesheetMetaDisplayEvent::EXPORT; + } + + $event = new TimesheetMetaDisplayEvent($query, $metaLocation); + $this->dispatcher->dispatch($event); + $timesheetMetaFields = $event->getFields(); + + $content = $this->twig->render('timesheet/export.html.twig', [ + 'entries' => $timesheets, + 'query' => $query, + 'metaColumns' => $timesheetMetaFields, + ]); + + $response = new Response(); + $response->setContent($content); + + return $response; + } + + /** + * @return string + */ + public function getId(): string + { + return 'print'; + } +} diff --git a/src/Export/Timesheet/PDFRenderer.php b/src/Export/Timesheet/PDFRenderer.php new file mode 100644 index 00000000..bf6e150e --- /dev/null +++ b/src/Export/Timesheet/PDFRenderer.php @@ -0,0 +1,17 @@ +registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN); $container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER); $container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET); + $container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER); /** @var SecurityExtension $extension */ $extension = $container->getExtension('security'); @@ -91,6 +95,10 @@ class Kernel extends BaseKernel return; } + if ($this->environment === 'test' && getenv('TEST_WITH_BUNDLES') === false) { + return; + } + $finder = new Finder(); $finder->ignoreUnreadableDirs()->directories()->name('*Bundle'); /** @var SplFileInfo $bundleDir */ @@ -149,6 +157,7 @@ class Kernel extends BaseKernel $container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); + $container->addCompilerPass(new ExportServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); } @@ -172,6 +181,10 @@ class Kernel extends BaseKernel // load application routes $routes->import($confDir . '/routes' . self::CONFIG_EXTS, '/', 'glob'); + if ($this->environment === 'test' && getenv('TEST_WITH_BUNDLES') === false) { + return; + } + // load plugin routes $pluginsDir = $this->getProjectDir() . '/var/plugins'; if (!file_exists($pluginsDir)) { diff --git a/src/Twig/TimesheetExtension.php b/src/Twig/TimesheetExtension.php new file mode 100644 index 00000000..1646746e --- /dev/null +++ b/src/Twig/TimesheetExtension.php @@ -0,0 +1,47 @@ +service = $service; + } + + /** + * {@inheritdoc} + */ + public function getFunctions() + { + return [ + new TwigFunction('timesheet_exporter', [$this, 'getTimesheetExporter'], []), + ]; + } + + public function getTimesheetExporter(): array + { + $ids = []; + foreach ($this->service->getTimesheetExporter() as $exporter) { + $ids[] = $exporter->getId(); + } + + return $ids; + } +} diff --git a/templates/export/renderer/pdf.html.twig b/templates/export/renderer/pdf.html.twig index 72c0a6db..a9b49c3a 100644 --- a/templates/export/renderer/pdf.html.twig +++ b/templates/export/renderer/pdf.html.twig @@ -1,3 +1,11 @@ +{% set showUserColumn = true %} +{% set showRateColumn = true %} +{% if query.user %} + {# this is only triggered, if a user exports from his personal timesheet screen#} + {% set showUserColumn = false %} + {% set showRateColumn = is_granted('view_rate_own_timesheet') %} + {# TODO if exporting via the admin screen, users without view_rate_own_timesheet might still see their own rates, maybe merge view_rate_own_timesheet and view_rate_other_timesheet into a new view_rate permission? #} +{% endif %}