From 6d78c6ba36b88e9e485b72f8460b6ff2ed9ca8a8 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Tue, 23 Sep 2025 18:32:31 +0200 Subject: [PATCH] Configurable PDF exports (#5641) --- config/services.yaml | 1 + phpstan.neon | 120 ------ src/Controller/ExportController.php | 20 +- src/Entity/ExportTemplate.php | 102 ++++- src/Entity/User.php | 3 + .../Base/AbstractSpreadsheetRenderer.php | 91 ++++ src/Export/Base/CsvRenderer.php | 51 +-- src/Export/Base/ExportTrait.php | 31 -- src/Export/Base/HtmlRenderer.php | 36 +- src/Export/Base/PDFRenderer.php | 44 +- src/Export/Base/PdfTemplateRenderer.php | 133 ++++++ src/Export/Base/RendererTrait.php | 45 +- src/Export/Base/SpreadsheetRenderer.php | 401 ------------------ src/Export/Base/XlsxRenderer.php | 40 +- src/Export/ColumnConverter.php | 258 +++++++++++ src/Export/DefaultTemplate.php | 145 +++++++ src/Export/ExportRendererInterface.php | 5 + src/Export/Renderer/CsvRendererFactory.php | 26 +- src/Export/Renderer/HtmlRendererFactory.php | 14 +- src/Export/Renderer/PdfRendererFactory.php | 34 +- src/Export/Renderer/XlsxRendererFactory.php | 26 +- src/Export/ServiceExport.php | 32 +- src/Export/Template.php | 4 +- src/Export/TemplateInterface.php | 4 +- src/Export/Timesheet/HtmlRenderer.php | 26 -- src/Export/TimesheetExportInterface.php | 13 +- src/Form/ExportTemplateSpreadsheetForm.php | 53 ++- src/Form/Type/ExportRendererType.php | 6 +- src/Form/Type/ExportSummaryColumnsType.php | 47 ++ src/Form/Type/PdfFontType.php | 48 +++ src/Pdf/PdfContext.php | 7 +- src/Pdf/PdfRendererTrait.php | 2 +- .../Constraints/ExportRendererValidator.php | 2 +- templates/export/index.html.twig | 8 +- .../default.html.twig => print.html.twig} | 0 templates/export/renderer.pdf.twig | 326 ++++++++++++++ .../export/renderer/default-budget.pdf.twig | 11 - .../export/renderer/default-internal.pdf.twig | 2 - templates/export/renderer/default.pdf.twig | 1 - templates/export/template.html.twig | 5 +- tests/Controller/ExportControllerTest.php | 14 +- tests/Entity/ExportTemplateTest.php | 34 +- tests/Export/Base/CsvRendererTest.php | 51 +-- tests/Export/Base/HtmlRendererTest.php | 73 ++++ tests/Export/Base/PdfRendererTest.php | 57 ++- tests/Export/Base/PdfTemplateRendererTest.php | 193 +++++++++ tests/Export/Base/SpreadsheetRendererTest.php | 193 --------- tests/Export/Base/XlsxRendererTest.php | 31 +- tests/Export/ColumnConverterTest.php | 291 +++++++++++++ tests/Export/DefaultTemplateTest.php | 114 +++++ tests/Export/Package/ColumnTest.php | 27 +- tests/Export/Package/MemoryPackage.php | 3 - .../Renderer/AbstractRendererTestCase.php | 6 +- .../Renderer/CsvRendererFactoryTest.php | 13 +- .../Renderer/HtmlRendererFactoryTest.php | 1 + tests/Export/Renderer/HtmlRendererTest.php | 5 +- .../Renderer/PdfRendererFactoryTest.php | 27 +- tests/Export/Renderer/PdfRendererTest.php | 7 +- .../Renderer/XlsxRendererFactoryTest.php | 13 +- tests/Export/ServiceExportTest.php | 30 +- .../Spreadsheet/ColumnDefinitionTest.php | 4 + tests/Export/TemplateTest.php | 7 +- .../Timesheet/AbstractRendererTestCase.php | 4 +- tests/Export/Timesheet/HtmlRendererTest.php | 63 --- tests/Export/Timesheet/PdfRendererTest.php | 7 +- tests/Mocks/Export/CsvRendererFactoryMock.php | 13 +- tests/Mocks/Export/PdfRendererFactoryMock.php | 13 + .../Mocks/Export/XlsxRendererFactoryMock.php | 13 +- tests/phpstan.neon | 5 - translations/export.ar.xlf | 12 - translations/export.bg.xlf | 12 - translations/export.cs.xlf | 12 - translations/export.da.xlf | 12 - translations/export.de.xlf | 12 - translations/export.de_CH.xlf | 12 - translations/export.el.xlf | 12 - translations/export.en.xlf | 12 - translations/export.eo.xlf | 13 +- translations/export.es.xlf | 12 - translations/export.et.xlf | 13 +- translations/export.fa.xlf | 12 - translations/export.fi.xlf | 12 - translations/export.fr.xlf | 12 - translations/export.he.xlf | 12 - translations/export.hr.xlf | 12 - translations/export.hu.xlf | 12 - translations/export.id.xlf | 12 - translations/export.it.xlf | 12 - translations/export.ja.xlf | 4 - translations/export.ko.xlf | 12 - translations/export.nb_NO.xlf | 12 - translations/export.nl.xlf | 12 - translations/export.pl.xlf | 12 - translations/export.pt.xlf | 12 - translations/export.pt_BR.xlf | 12 - translations/export.ro.xlf | 12 - translations/export.ru.xlf | 12 - translations/export.sk.xlf | 12 - translations/export.sv.xlf | 12 - translations/export.ta.xlf | 12 - translations/export.tr.xlf | 12 - translations/export.uk.xlf | 12 - translations/export.vi.xlf | 12 - translations/export.zh_CN.xlf | 12 - translations/export.zh_Hant.xlf | 12 - translations/messages.de.xlf | 22 +- translations/messages.en.xlf | 20 + 107 files changed, 2428 insertions(+), 1575 deletions(-) create mode 100644 src/Export/Base/AbstractSpreadsheetRenderer.php delete mode 100644 src/Export/Base/ExportTrait.php create mode 100644 src/Export/Base/PdfTemplateRenderer.php delete mode 100644 src/Export/Base/SpreadsheetRenderer.php create mode 100644 src/Export/ColumnConverter.php create mode 100644 src/Export/DefaultTemplate.php delete mode 100644 src/Export/Timesheet/HtmlRenderer.php create mode 100644 src/Form/Type/ExportSummaryColumnsType.php create mode 100644 src/Form/Type/PdfFontType.php rename templates/export/{renderer/default.html.twig => print.html.twig} (100%) create mode 100644 templates/export/renderer.pdf.twig delete mode 100644 templates/export/renderer/default-budget.pdf.twig delete mode 100644 templates/export/renderer/default-internal.pdf.twig delete mode 100644 templates/export/renderer/default.pdf.twig create mode 100644 tests/Export/Base/HtmlRendererTest.php create mode 100644 tests/Export/Base/PdfTemplateRendererTest.php delete mode 100644 tests/Export/Base/SpreadsheetRendererTest.php create mode 100644 tests/Export/ColumnConverterTest.php create mode 100644 tests/Export/DefaultTemplateTest.php delete mode 100644 tests/Export/Timesheet/HtmlRendererTest.php diff --git a/config/services.yaml b/config/services.yaml index c5d424f9..d03dbf59 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,6 +24,7 @@ services: - '../src/Entity/' - '../src/Event/' - '../src/Export/Package/' + - '../src/Export/Base/' - '../src/Form/Model/' - '../src/Model/' - '../src/Repository/Loader/' diff --git a/phpstan.neon b/phpstan.neon index 1ab78a9a..d82fec5b 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1518,121 +1518,11 @@ parameters: count: 1 path: src/Export/Annotation/Order.php - - - message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 5 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#" - count: 2 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/HtmlRenderer.php - - message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 path: src/Export/Base/HtmlRenderer.php - - - message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 5 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#" - count: 2 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - - - message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#" count: 1 @@ -1643,11 +1533,6 @@ parameters: count: 1 path: src/Export/Base/PDFRenderer.php - - - message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#" - count: 1 - path: src/Export/Base/PDFRenderer.php - - message: "#^Property App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:\\$pdfOptions type has no value type specified in iterable type array\\.$#" count: 1 @@ -3098,11 +2983,6 @@ parameters: count: 2 path: src/Invoice/Renderer/DocxRenderer.php - - - message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#" - count: 1 - path: src/Invoice/Renderer/PdfRenderer.php - - message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#" count: 1 diff --git a/src/Controller/ExportController.php b/src/Controller/ExportController.php index e18e3c6b..396fbae6 100644 --- a/src/Controller/ExportController.php +++ b/src/Controller/ExportController.php @@ -85,13 +85,21 @@ final class ExportController extends AbstractController $buttons = []; foreach ($this->export->getRenderer() as $renderer) { - $class = \get_class($renderer); - $pos = strrpos($class, '\\'); - if ($pos !== false) { - $class = substr($class, $pos + 1); + if (method_exists($renderer, 'getType')) { + $class = $renderer->getType(); + } else { + // TODO remove me with 3.0 + $class = \get_class($renderer); + $pos = strrpos($class, '\\'); + if ($pos !== false) { + $class = substr($class, $pos + 1); + } + $class = strtolower(str_replace('Renderer', '', $class)); } - $class = strtolower(str_replace('Renderer', '', $class)); - $buttons[$class][$renderer->getId()] = $renderer->getTitle(); + $buttons[$class][$renderer->getId()] = [ + 'title' => $renderer->getTitle(), + 'internal' => method_exists($renderer, 'isInternal') ? $renderer->isInternal() : false, + ]; } if ($this->isGranted('view_other_timesheet')) { diff --git a/src/Entity/ExportTemplate.php b/src/Entity/ExportTemplate.php index 4d28be3b..96220a16 100644 --- a/src/Entity/ExportTemplate.php +++ b/src/Entity/ExportTemplate.php @@ -106,7 +106,7 @@ class ExportTemplate $this->columns = $columns ?? []; } - public function getOption(string $key, int|string|bool $default): int|string|bool + public function getOption(string $key, int|string|bool|null $default): int|string|bool|null { if (\array_key_exists($key, $this->options)) { return $this->options[$key] ?? $default; @@ -115,8 +115,16 @@ class ExportTemplate return $default; } - public function setOption(string $key, int|string|null|bool $value): void + public function setOption(string $key, int|string|bool|null $value): void { + if ($value === null) { + if (\array_key_exists($key, $this->options)) { + unset($this->options[$key]); + } + + return; + } + $this->options[$key] = $value; } @@ -153,6 +161,96 @@ class ExportTemplate return (string) $this->getOption('separator', ','); } + /** + * Only used for PDF export + */ + public function setName(?string $name): void + { + $this->setOption('name', $name); + } + + public function getName(): ?string + { + $name = $this->getOption('name', null); + + return \is_string($name) ? $name : null; + } + + /** + * Only used for PDF export + */ + public function setPageSize(?string $pageSize): void + { + $this->setOption('pageSize', $pageSize); + } + + public function getPageSize(): ?string + { + $pageSize = $this->getOption('pageSize', null); + + return \is_string($pageSize) ? $pageSize : null; + } + + /** + * Only used for PDF export + */ + public function setOrientation(?string $orientation): void + { + if ($orientation !== null) { + $orientation = strtolower($orientation); + if (!\in_array($orientation, ['landscape', 'portrait'], true)) { + throw new \InvalidArgumentException('Invalid orientation. Allowed values are "landscape" and "portrait".'); + } + } + $this->setOption('orientation', $orientation); + } + + public function getOrientation(): ?string + { + $orientation = $this->getOption('orientation', null); + + return \is_string($orientation) ? $orientation : null; + } + + /** + * Only used for PDF export + * @param array $columns + */ + public function setSummaryColumns(array $columns): void + { + $columns = \count($columns) > 0 ? implode(',', $columns) : null; + + $this->setOption('summary_columns', $columns); + } + + /** + * @return array + */ + public function getSummaryColumns(): array + { + $columns = $this->getOption('summary_columns', null); + if (!\is_string($columns)) { + return []; + } + + return explode(',', $columns); + } + + /** + * Only used for PDF export + */ + public function setFont(?string $font): void + { + $this->setOption('font', $font); + } + + public function getFont(): ?string + { + $font = $this->getOption('font', null); + + return \is_string($font) ? $font : null; + } + public function __toString(): string { return $this->title ?? 'New'; diff --git a/src/Entity/User.php b/src/Entity/User.php index afdda8f4..5cd5fc4a 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -468,6 +468,9 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas return $this->getPreferenceValue(UserPreference::FIRST_WEEKDAY, User::DEFAULT_FIRST_WEEKDAY, false); } + /** + * @ deprecated since 2.40 - will be removed with 3.0 + */ public function isExportDecimal(): bool { return (bool) $this->getPreferenceValue('export_decimal', false, false); diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php new file mode 100644 index 00000000..6c554dcf --- /dev/null +++ b/src/Export/Base/AbstractSpreadsheetRenderer.php @@ -0,0 +1,91 @@ +internal; + } + + public function setInternal(bool $internal): void + { + $this->internal = $internal; + } + + protected function getFileResponse(string $file, string $filename, string $contentType): BinaryFileResponse + { + $response = new BinaryFileResponse($file); + $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + + $response->headers->set('Content-Type', $contentType); + $response->headers->set('Content-Disposition', $disposition); + $response->deleteFileAfterSend(true); + + return $response; + } + + /** + * @param ExportableItem[] $exportItems + */ + protected function writeSpreadsheet(ColumnConverter $converter, TemplateInterface $template, SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void + { + /** @var array $columns */ + $columns = $converter->getColumns($template, $query); + $spreadsheetPackage->setColumns($columns); + + $currentRow = 1; + foreach ($exportItems as $exportItem) { + $cells = []; + foreach ($columns as $column) { + $cells[] = $column->getValue($exportItem); + } + $spreadsheetPackage->addRow($cells); + $currentRow++; + } + + if ($currentRow > 1) { + $totalColumns = ['duration', 'rate', 'internalRate']; + // that should be enough for the near future: the number of array entries must cover the max number of columns + $columnNames = [ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', + 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', + ]; + $totalRow = []; + $totalColumn = 1; + foreach ($columns as $column) { + $formula = null; + if (\in_array($column->getName(), $totalColumns)) { + $columnName = $columnNames[$totalColumn - 1]; + $formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow); + } + $totalRow[] = $formula; + $totalColumn++; + } + + $spreadsheetPackage->addRow($totalRow, ['totals' => true]); + } + + $spreadsheetPackage->save(); + } +} diff --git a/src/Export/Base/CsvRenderer.php b/src/Export/Base/CsvRenderer.php index fd7ad0ae..f7e963f1 100644 --- a/src/Export/Base/CsvRenderer.php +++ b/src/Export/Base/CsvRenderer.php @@ -10,56 +10,44 @@ namespace App\Export\Base; use App\Entity\ExportableItem; +use App\Export\ColumnConverter; use App\Export\ExportFilename; +use App\Export\ExportRendererInterface; use App\Export\Package\CellFormatter\DateStringFormatter; use App\Export\Package\CellFormatter\DurationPlainFormatter; use App\Export\Package\SpoutSpreadsheet; -use App\Export\RendererInterface; -use App\Export\TimesheetExportInterface; +use App\Export\TemplateInterface; use App\Repository\Query\TimesheetQuery; use OpenSpout\Writer\CSV\Options; use OpenSpout\Writer\CSV\Writer; +use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Translation\TranslatorInterface; -final class CsvRenderer implements RendererInterface, TimesheetExportInterface +#[Exclude] +final class CsvRenderer extends AbstractSpreadsheetRenderer implements ExportRendererInterface { - use ExportTrait; - - private string $id = 'csv'; - private string $title = 'default'; - private ?string $locale = null; - public function __construct( - private readonly SpreadsheetRenderer $spreadsheetRenderer, - private readonly TranslatorInterface $translator + private readonly ColumnConverter $columnConverter, + private readonly TranslatorInterface $translator, + private readonly TemplateInterface $template, ) { } - public function setId(string $id): void + public function getType(): string { - $this->id = $id; + return 'csv'; } public function getId(): string { - return $this->id; - } - - public function setTitle(string $title): void - { - $this->title = $title; - } - - public function setLocale(?string $locale): void - { - $this->locale = $locale; + return $this->template->getId(); } public function getTitle(): string { - return $this->title; + return $this->template->getTitle(); } /** @@ -87,18 +75,19 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface $options = new Options(); $options->SHOULD_ADD_BOM = false; - $opts = $this->spreadsheetRenderer->getTemplate($query)->getOptions(); + $opts = $this->template->getOptions(); if (\array_key_exists('separator', $opts) && $opts['separator'] === ';') { $options->FIELD_DELIMITER = ';'; } - $spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale()); + $spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->template->getLocale()); $spreadsheet->open($filename); - $this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter()); - $this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter(false)); - $this->spreadsheetRenderer->registerFormatter('duration_seconds', new DurationPlainFormatter(true)); - $this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query); + $this->columnConverter->registerFormatter('date', new DateStringFormatter()); + $this->columnConverter->registerFormatter('duration', new DurationPlainFormatter(false)); + $this->columnConverter->registerFormatter('duration_seconds', new DurationPlainFormatter(true)); + + $this->writeSpreadsheet($this->columnConverter, $this->template, $spreadsheet, $exportItems, $query); return new \SplFileInfo($filename); } diff --git a/src/Export/Base/ExportTrait.php b/src/Export/Base/ExportTrait.php deleted file mode 100644 index a62f4437..00000000 --- a/src/Export/Base/ExportTrait.php +++ /dev/null @@ -1,31 +0,0 @@ -headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); - - $response->headers->set('Content-Type', $contentType); - $response->headers->set('Content-Disposition', $disposition); - $response->deleteFileAfterSend(true); - - return $response; - } -} diff --git a/src/Export/Base/HtmlRenderer.php b/src/Export/Base/HtmlRenderer.php index b0a5d9c8..fea8dce3 100644 --- a/src/Export/Base/HtmlRenderer.php +++ b/src/Export/Base/HtmlRenderer.php @@ -26,25 +26,41 @@ use App\Repository\Query\ProjectQuery; use App\Repository\Query\TimesheetQuery; use App\Twig\SecurityPolicy\ExportPolicy; use Psr\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; use Twig\Extension\SandboxExtension; +/** + * TODO 3.0 remove default values from constructor parameters and make class final + * @final + */ +#[Exclude] class HtmlRenderer implements ExportRendererInterface { use RendererTrait; - private string $id = 'html'; - private string $template = 'default.html.twig'; - public function __construct( protected readonly Environment $twig, protected readonly EventDispatcherInterface $dispatcher, private readonly ProjectStatisticService $projectStatisticService, - private readonly ActivityStatisticService $activityStatisticService + private readonly ActivityStatisticService $activityStatisticService, + private string $id = 'html', // deprecated default parameter - TODO 3.0 + private readonly string $title = 'print', // deprecated default parameter - TODO 3.0 + private string $template = 'export/print.html.twig', // deprecated default parameter - TODO 3.0 ) { } + public function isInternal(): bool + { + return false; + } + + public function getType(): string + { + return 'html'; + } + /** * @return MetaTableTypeInterface[] */ @@ -109,14 +125,20 @@ class HtmlRenderer implements ExportRendererInterface protected function getTemplate(): string { - return '@export/' . $this->template; + return $this->template; } + /** + * @deprecated since 2.40.0 + */ public function setTemplate(string $filename): void { - $this->template = $filename; + $this->template = '@export/' . $filename; } + /** + * @deprecated since 2.40.0 + */ public function setId(string $id): void { $this->id = $id; @@ -129,6 +151,6 @@ class HtmlRenderer implements ExportRendererInterface public function getTitle(): string { - return 'print'; + return $this->title; } } diff --git a/src/Export/Base/PDFRenderer.php b/src/Export/Base/PDFRenderer.php index f65f7f86..0aecdb66 100644 --- a/src/Export/Base/PDFRenderer.php +++ b/src/Export/Base/PDFRenderer.php @@ -12,35 +12,50 @@ namespace App\Export\Base; use App\Entity\ExportableItem; use App\Export\ExportFilename; use App\Export\ExportRendererInterface; -use App\Export\TimesheetExportInterface; use App\Pdf\HtmlToPdfConverter; use App\Pdf\PdfContext; use App\Pdf\PdfRendererTrait; use App\Project\ProjectStatisticService; use App\Repository\Query\TimesheetQuery; use App\Twig\SecurityPolicy\ExportPolicy; +use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\HttpFoundation\Response; use Twig\Environment; use Twig\Extension\SandboxExtension; -class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface, TimesheetExportInterface +/** + * TODO 3.0 remove default values from constructor parameters and make class final + * @final + */ +#[Exclude] +class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface { use RendererTrait; use PDFRendererTrait; - private string $id = 'pdf'; - private string $title = 'pdf'; - private string $template = 'default.pdf.twig'; private array $pdfOptions = []; public function __construct( private readonly Environment $twig, private readonly HtmlToPdfConverter $converter, - private readonly ProjectStatisticService $projectStatisticService + private readonly ProjectStatisticService $projectStatisticService, + private string $id = 'pdf', // deprecated default parameter - TODO 3.0 + private string $title = 'pdf', // deprecated default parameter - TODO 3.0 + private string $template = 'export/pdf-layout.html.twig', // deprecated default parameter - TODO 3.0 ) { } + public function isInternal(): bool + { + return false; + } + + public function getType(): string + { + return 'pdf'; + } + public function getTitle(): string { return $this->title; @@ -48,7 +63,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface protected function getTemplate(): string { - return '@export/' . $this->template; + return $this->template; } protected function getOptions(TimesheetQuery $query): array @@ -68,11 +83,9 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface return $this->pdfOptions; } - public function setPdfOption(string $key, string $value): PDFRenderer + public function setPdfOption(string $key, string $value): void { $this->pdfOptions[$key] = $value; - - return $this; } /** @@ -110,16 +123,25 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface return $this->createPdfResponse($content, $context); } + /** + * @deprecated since 2.40.0 + */ public function setTemplate(string $filename): void { - $this->template = $filename; + $this->template = '@export/' . $filename; } + /** + * @deprecated since 2.40.0 + */ public function setId(string $id): void { $this->id = $id; } + /** + * @deprecated since 2.40.0 + */ public function setTitle(string $title): void { $this->title = $title; diff --git a/src/Export/Base/PdfTemplateRenderer.php b/src/Export/Base/PdfTemplateRenderer.php new file mode 100644 index 00000000..a7148054 --- /dev/null +++ b/src/Export/Base/PdfTemplateRenderer.php @@ -0,0 +1,133 @@ +template->getTitle(); + } + + public function getId(): string + { + return $this->template->getId(); + } + + /** + * @param ExportableItem[] $exportItems + */ + public function render(array $exportItems, TimesheetQuery $query): Response + { + $filename = new ExportFilename($query); + $context = new PdfContext(); + $context->setOption('filename', $filename->getFilename()); + $context->setOption('margin_top', 22); + $context->setOption('margin_bottom', 22); + $context->setOption('PDFA', true); + $context->setOption('PDFAauto', true); + + $options = $this->template->getOptions(); + + $summaryColumns = []; + if (\array_key_exists('summary_columns', $options) && \is_string($options['summary_columns']) && $options['summary_columns'] !== '') { + $summaryColumns = $options['summary_columns']; + $summaryColumns = explode(',', $summaryColumns); + unset($options['summary_columns']); + } + $options['summary'] = $summaryColumns; + if (\count($summaryColumns) > 0) { + $options['summaries'] = $this->calculateSummary($exportItems); + $options['budgets'] = $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService); + } + + $font = 'sans-serif'; + if (\array_key_exists('font', $options) && \in_array($options['font'], PdfFontType::AVAILABLE_FONTS, true)) { + $font = $options['font']; + } + + $format = 'A4'; + if (\array_key_exists('pageSize', $options) && \is_string($options['pageSize']) && \in_array($options['pageSize'], ['A4', 'A5', 'A6', 'Legal', 'Letter'])) { + $format = $options['pageSize']; + } + if (\array_key_exists('orientation', $options) && $options['orientation'] === 'landscape') { + $format .= '-L'; + } + $context->setOption('format', $format); + + $oldLocale = null; + $locale = $this->template->getLocale(); + if ($locale !== null) { + $oldLocale = $this->localeSwitcher->getLocale(); + $this->localeSwitcher->setLocale($locale); + } + + $content = $this->twig->render('export/renderer.pdf.twig', array_merge([ + 'template' => $this->template, + 'title' => $options['name'] ?? null, + 'locale' => $locale, + 'entries' => $exportItems, + 'query' => $query, + 'font' => $font, + 'columns' => $this->columnConverter->getColumns($this->template, $query), + ], $options)); + + if ($oldLocale !== null) { + $this->localeSwitcher->setLocale($locale); + } + + $pdfOptions = array_merge($context->getOptions(), $options); + + $content = $this->converter->convertToPdf($content, $pdfOptions); + + return $this->createPdfResponse($content, $context); + } +} diff --git a/src/Export/Base/RendererTrait.php b/src/Export/Base/RendererTrait.php index 452ed1a2..e6f2f315 100644 --- a/src/Export/Base/RendererTrait.php +++ b/src/Export/Base/RendererTrait.php @@ -21,7 +21,7 @@ trait RendererTrait * FIXME use statistic events to calculate budgets and do NOT iterate all results! * * @param ExportableItem[] $exportItems - * @return array + * @return array */ protected function calculateSummary(array $exportItems): array { @@ -38,10 +38,12 @@ trait RendererTrait $currency = null; if (null !== ($project = $exportItem->getProject())) { - $customer = $project->getCustomer(); - $customerId = $customer->getId(); $projectId = $project->getId(); - $currency = $customer->getCurrency(); + $customer = $project->getCustomer(); + if ($customer !== null) { + $customerId = $customer->getId(); + $currency = $customer->getCurrency(); + } } if (null !== ($activity = $exportItem->getActivity())) { @@ -72,9 +74,12 @@ trait RendererTrait 'users' => [] ]; - if ($project !== null) { + if ($customer !== null) { $summary[$id]['customer'] = $customer->getName(); $summary[$id]['customer_item'] = $customer; + } + + if ($project !== null) { $summary[$id]['project'] = $project->getName(); $summary[$id]['project_item'] = $project; } @@ -171,7 +176,7 @@ trait RendererTrait * @param ExportableItem[] $exportItems * @param TimesheetQuery $query * @param ProjectStatisticService $projectStatisticService - * @return array + * @return array */ protected function calculateProjectBudget(array $exportItems, TimesheetQuery $query, ProjectStatisticService $projectStatisticService): array { @@ -184,12 +189,16 @@ trait RendererTrait $project = null; $customerId = 'none'; $projectId = 'none'; + $time = 0; + $money = 0; if (null !== ($project = $exportItem->getProject())) { $customer = $project->getCustomer(); - $customerId = $customer->getId(); + $customerId = $customer?->getId(); $projectId = $project->getId(); $projects[] = $project; + $time = $project->getTimeBudget(); + $money = $project->getBudget(); } $id = $customerId . '_' . $projectId; @@ -197,8 +206,8 @@ trait RendererTrait if (!isset($summary[$id])) { $summary[$id] = [ 'totals' => $empty->jsonSerialize(), - 'time' => $project->getTimeBudget(), - 'money' => $project->getBudget(), + 'time' => $time, + 'money' => $money, 'time_left' => null, 'money_left' => null, 'time_left_total' => null, @@ -213,8 +222,11 @@ trait RendererTrait foreach ($allBudgets as $projectId => $statisticModel) { $project = $statisticModel->getProject(); - $id = $project->getCustomer()->getId() . '_' . $projectId; + $id = $project->getCustomer()?->getId() . '_' . $projectId; $total = $statisticModel->getStatisticTotal(); + if ($total === null) { + continue; + } $summary[$id]['totals'] = $total->jsonSerialize(); if ($statisticModel->hasTimeBudget()) { $summary[$id]['time_left'] = $statisticModel->getTimeBudgetOpenRelative(); @@ -256,7 +268,7 @@ trait RendererTrait * @param ExportableItem[] $exportItems * @param TimesheetQuery $query * @param ActivityStatisticService $activityStatisticService - * @return array + * @return array */ protected function calculateActivityBudget(array $exportItems, TimesheetQuery $query, ActivityStatisticService $activityStatisticService): array { @@ -282,7 +294,7 @@ trait RendererTrait if (null !== ($project = $exportItem->getProject())) { $projectId = $project->getId(); - $customerId = $project->getCustomer()->getId(); + $customerId = $project->getCustomer()?->getId(); } $id = $customerId . '_' . $projectId; @@ -312,9 +324,14 @@ trait RendererTrait foreach ($allBudgets as $activityId => $statisticModel) { $project = $statisticModel->getActivity()->getProject(); - $id = $project->getCustomer()->getId() . '_' . $project->getId(); + if ($project === null) { + continue; + } + $id = $project->getCustomer()?->getId() . '_' . $project->getId(); $total = $statisticModel->getStatisticTotal(); - $summary[$id][$activityId]['totals'] = $total->jsonSerialize(); + if ($total !== null) { + $summary[$id][$activityId]['totals'] = $total->jsonSerialize(); + } if ($statisticModel->hasTimeBudget()) { $summary[$id][$activityId]['time_left'] = $statisticModel->getTimeBudgetOpenRelative(); $summary[$id][$activityId]['time_left_total'] = $statisticModel->getTimeBudgetOpen(); diff --git a/src/Export/Base/SpreadsheetRenderer.php b/src/Export/Base/SpreadsheetRenderer.php deleted file mode 100644 index 190ac05d..00000000 --- a/src/Export/Base/SpreadsheetRenderer.php +++ /dev/null @@ -1,401 +0,0 @@ - - */ - private array $formatter = []; - - private ?TemplateInterface $template = null; - - public function __construct( - private readonly EventDispatcherInterface $eventDispatcher, - private readonly Security $voter, - private readonly ?LoggerInterface $logger = null, - ) { - } - - public function setTemplate(?TemplateInterface $template): void - { - $this->template = $template; - } - - public function getTemplate(?TimesheetQuery $query = null): TemplateInterface - { - if ($this->template === null) { - $template = new Template('default', 'default'); - $template->setColumns($this->getDefaultColumns($query)); - $template->setLocale('en'); - - $this->template = $template; - } - - return $this->template; - } - - private function isRenderRate(TimesheetQuery $query): bool - { - if ($this->voter->getUser() === null) { - // for command line export - return true; - } - - if (null !== $query->getUser()) { - return $this->voter->isGranted('view_rate_own_timesheet'); - } - - return $this->voter->isGranted('view_rate_other_timesheet'); - } - - /** - * @return MetaTableTypeInterface[] - */ - private function findMetaColumns(MetaDisplayEventInterface $event): array - { - $this->eventDispatcher->dispatch($event); - - return $event->getFields(); - } - - /** - * @param ExportableItem[] $exportItems - */ - public function writeSpreadsheet(SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void - { - $columns = $this->getColumns($query); - $spreadsheetPackage->setColumns($columns); - - $currentRow = 1; - foreach ($exportItems as $exportItem) { - $cells = []; - foreach ($columns as $column) { - $cells[] = $column->getValue($exportItem); - } - $spreadsheetPackage->addRow($cells); - $currentRow++; - } - - if ($currentRow > 1) { - $totalColumns = ['duration', 'rate', 'internalRate']; - // that should be enough for the near future: the number of array entries must cover the max number of columns - $columnNames = [ - 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', - 'AA', 'AB', 'AC', 'AD', 'AE', 'AF', 'AG', 'AH', 'AI', 'AJ', 'AK', 'AL', 'AM', 'AN', 'AO', 'AP', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AV', 'AW', 'AX', 'AY', 'AZ', - 'BA', 'BB', 'BC', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BK', 'BL', 'BM', 'BN', 'BO', 'BP', 'BQ', 'BR', 'BS', 'BT', 'BU', 'BV', 'BW', 'BX', 'BY', 'BZ', - ]; - $totalRow = []; - $totalColumn = 1; - foreach ($columns as $column) { - $formula = null; - if (\in_array($column->getName(), $totalColumns)) { - $columnName = $columnNames[$totalColumn - 1]; - $formula = \sprintf('=SUBTOTAL(9,%s2:%s%s)', $columnName, $columnName, $currentRow); - } - $totalRow[] = $formula; - $totalColumn++; - } - - $spreadsheetPackage->addRow($totalRow, ['totals' => true]); - } - - $spreadsheetPackage->save(); - } - - public function registerFormatter(string $name, CellFormatterInterface $cellFormatter): void - { - $this->formatter[$name] = $cellFormatter; - } - - private function getFormatter(string $name): CellFormatterInterface - { - if (\array_key_exists($name, $this->formatter)) { - return $this->formatter[$name]; - } - - return match ($name) { - 'date' => new DateFormatter(), - 'time' => new TimeFormatter(), - 'duration' => new DurationFormatter('[hh]:mm'), - 'duration_decimal' => new DurationDecimalFormatter(), - 'duration_seconds' => new DurationFormatter('[hh]:mm:ss'), - default => new DefaultFormatter() - }; - } - - /** - * @return array - */ - private function getColumns(TimesheetQuery $query): array - { - $showRates = $this->isRenderRate($query); - - $timesheetMeta = []; - foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default'))) - ->withHeader($metaField->getLabel()) - ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { - return $exportableItem->getMetaField($metaField->getName())?->getValue(); - }); - } - } - - $customerMeta = []; - foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default'))) - ->withHeader($metaField->getLabel()) - ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { - return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue(); - }); - } - } - - $projectMeta = []; - foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default'))) - ->withHeader($metaField->getLabel()) - ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { - return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue(); - }); - } - } - - $activityMeta = []; - foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default'))) - ->withHeader($metaField->getLabel()) - ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { - return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue(); - }); - } - } - - $userMeta = []; - $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); - $this->eventDispatcher->dispatch($event); - foreach ($event->getPreferences() as $metaField) { - if ($metaField->getName() !== null) { - $userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default'))) - ->withHeader($metaField->getLabel()) - ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { - return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue(); - }); - } - } - - $template = $this->getTemplate($query); - - $columns = []; - - $rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate']; - - foreach ($template->getColumns() as $column) { - if ($column === 'date') { - $columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin()); - } elseif ($column === 'begin') { - $columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'end') { - $columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'duration') { - $columns[] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'duration_decimal') { - $columns[] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'duration_seconds') { - $columns[] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'break') { - // TODO remove method_exists with 3.0 - $columns[] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType - } elseif ($column === 'break_decimal') { - // TODO remove method_exists with 3.0 - $columns[] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType - } elseif ($column === 'break_seconds') { - // TODO remove method_exists with 3.0 - $columns[] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType - } elseif ($column === 'currency' && $showRates) { - $columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'rate' && $showRates) { - $columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate()); - } elseif ($column === 'internal_rate' && $showRates) { - $columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate()); - } elseif ($column === 'hourly_rate' && $showRates) { - $columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate()); - } elseif ($column === 'fixed_rate' && $showRates) { - $columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate()); - } elseif ($column === 'user.alias') { - $columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'user.name') { - $columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'user.email') { - $columns[] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'user.account_number') { - $columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber()); - } elseif ($column === 'customer.name') { - $columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'project.name') { - $columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'activity.name') { - $columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); - } elseif ($column === 'description') { - $columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE); - } elseif ($column === 'exported') { - $columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported()); - } elseif ($column === 'billable') { - $columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL); - } elseif ($column === 'tags') { - $columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray()); - } elseif ($column === 'type') { - $columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType()); - } elseif ($column === 'category') { - $columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory()); - } elseif ($column === 'customer.number') { - $columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber()); - } elseif ($column === 'project.number') { - $columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber()); - } elseif ($column === 'activity.number') { - $columns[] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber()); - } elseif ($column === 'customer.vat_id') { - $columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId()); - } elseif ($column === 'project.order_number') { - $columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber()); - } elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) { - $columns[] = $timesheetMeta[$column]; - } elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) { - $columns[] = $customerMeta[$column]; - } elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) { - $columns[] = $projectMeta[$column]; - } elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) { - $columns[] = $activityMeta[$column]; - } elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) { - $columns[] = $userMeta[$column]; - } else { - if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) { - $this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle())); - } - } - } - - return $columns; - } - - /** - * @return array - */ - private function getDefaultColumns(?TimesheetQuery $query = null): array - { - // @deprecated from 2.36 - will be removed with 3.0 - $durationFormatter = 'duration'; - if (($user = $this->voter->getUser()) instanceof User) { - $durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration'; - } - - $columns = [ - 'date', - 'begin', - 'end', - $durationFormatter, - 'currency', - 'rate', - 'internal_rate', - 'hourly_rate', - 'fixed_rate', - 'user.alias', - 'user.name', - 'user.email', - 'user.account_number', - 'customer.name', - 'project.name', - 'activity.name', - 'description', - 'billable', - 'tags', - 'type', - 'category', - 'customer.number', - 'project.number', - 'customer.vat_id', - 'project.order_number', - ]; - - foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query ?? new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $columns[] = 'timesheet.meta.' . $metaField->getName(); - } - } - - foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $columns[] = 'customer.meta.' . $metaField->getName(); - } - } - - foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $columns[] = 'project.meta.' . $metaField->getName(); - } - } - - foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) { - if ($metaField->getName() !== null) { - $columns[] = 'activity.meta.' . $metaField->getName(); - } - } - - $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); - $this->eventDispatcher->dispatch($event); - foreach ($event->getPreferences() as $metaField) { - if ($metaField->getName() !== null) { - $columns[] = 'user.meta.' . $metaField->getName(); - } - } - - return $columns; - } -} diff --git a/src/Export/Base/XlsxRenderer.php b/src/Export/Base/XlsxRenderer.php index 701c84fc..93663304 100644 --- a/src/Export/Base/XlsxRenderer.php +++ b/src/Export/Base/XlsxRenderer.php @@ -10,53 +10,41 @@ namespace App\Export\Base; use App\Entity\ExportableItem; +use App\Export\ColumnConverter; use App\Export\ExportFilename; +use App\Export\ExportRendererInterface; use App\Export\Package\SpoutSpreadsheet; -use App\Export\RendererInterface; -use App\Export\TimesheetExportInterface; +use App\Export\TemplateInterface; use App\Repository\Query\TimesheetQuery; use OpenSpout\Writer\XLSX\Writer; +use Symfony\Component\DependencyInjection\Attribute\Exclude; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Translation\TranslatorInterface; -final class XlsxRenderer implements RendererInterface, TimesheetExportInterface +#[Exclude] +final class XlsxRenderer extends AbstractSpreadsheetRenderer implements ExportRendererInterface { - use ExportTrait; - - private string $id = 'xlsx'; - private string $title = 'default'; - private ?string $locale = null; - public function __construct( - private readonly SpreadsheetRenderer $spreadsheetRenderer, + private readonly ColumnConverter $columnConverter, private readonly TranslatorInterface $translator, + private readonly TemplateInterface $template, ) { } - public function setId(string $id): void + public function getType(): string { - $this->id = $id; + return 'xlsx'; } public function getId(): string { - return $this->id; - } - - public function setTitle(string $title): void - { - $this->title = $title; - } - - public function setLocale(?string $locale): void - { - $this->locale = $locale; + return $this->template->getId(); } public function getTitle(): string { - return $this->title; + return $this->template->getTitle(); } /** @@ -81,10 +69,10 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface throw new \Exception('Could not open temporary file'); } - $spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale ?? $this->spreadsheetRenderer->getTemplate($query)->getLocale()); + $spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->template->getLocale()); $spreadsheet->open($filename); - $this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query); + $this->writeSpreadsheet($this->columnConverter, $this->template, $spreadsheet, $exportItems, $query); return new \SplFileInfo($filename); } diff --git a/src/Export/ColumnConverter.php b/src/Export/ColumnConverter.php new file mode 100644 index 00000000..acefb1ac --- /dev/null +++ b/src/Export/ColumnConverter.php @@ -0,0 +1,258 @@ + + */ + private array $formatter = []; + + public function __construct( + private readonly EventDispatcherInterface $eventDispatcher, + private readonly Security $security, + private readonly ?LoggerInterface $logger = null, + ) { + } + + private function isRenderRate(TimesheetQuery $query): bool + { + if ($this->security->getUser() === null) { + // for command line export + return true; + } + + if (null !== $query->getUser()) { + return $this->security->isGranted('view_rate_own_timesheet'); + } + + return $this->security->isGranted('view_rate_other_timesheet'); + } + + /** + * @return MetaTableTypeInterface[] + */ + private function findMetaColumns(MetaDisplayEventInterface $event): array + { + $this->eventDispatcher->dispatch($event); + + return $event->getFields(); + } + + public function registerFormatter(string $name, CellFormatterInterface $cellFormatter): void + { + $this->formatter[$name] = $cellFormatter; + } + + private function getFormatter(string $name): CellFormatterInterface + { + if (\array_key_exists($name, $this->formatter)) { + return $this->formatter[$name]; + } + + return match ($name) { + 'date' => new DateFormatter(), + 'time' => new TimeFormatter(), + 'duration' => new DurationFormatter('[hh]:mm'), + 'duration_decimal' => new DurationDecimalFormatter(), + 'duration_seconds' => new DurationFormatter('[hh]:mm:ss'), + default => new DefaultFormatter() + }; + } + + /** + * @return array + */ + public function getColumns(TemplateInterface $template, TimesheetQuery $query): array + { + $showRates = $this->isRenderRate($query); + + $timesheetMeta = []; + foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $timesheetMeta['timesheet.meta.' . $metaField->getName()] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default'))) + ->withHeader($metaField->getLabel()) + ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { + return $exportableItem->getMetaField($metaField->getName())?->getValue(); + }); + } + } + + $customerMeta = []; + foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $customerMeta['customer.meta.' . $metaField->getName()] = (new Column('customer.meta.' . $metaField->getName(), $this->getFormatter('default'))) + ->withHeader($metaField->getLabel()) + ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { + return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue(); + }); + } + } + + $projectMeta = []; + foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $projectMeta['project.meta.' . $metaField->getName()] = (new Column('project.meta.' . $metaField->getName(), $this->getFormatter('default'))) + ->withHeader($metaField->getLabel()) + ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { + return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue(); + }); + } + } + + $activityMeta = []; + foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $activityMeta['activity.meta.' . $metaField->getName()] = (new Column('activity.meta.' . $metaField->getName(), $this->getFormatter('default'))) + ->withHeader($metaField->getLabel()) + ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { + return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue(); + }); + } + } + + $userMeta = []; + $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); + $this->eventDispatcher->dispatch($event); + foreach ($event->getPreferences() as $metaField) { + if ($metaField->getName() !== null) { + $userMeta['user.meta.' . $metaField->getName()] = (new Column('user.meta.' . $metaField->getName(), $this->getFormatter('default'))) + ->withHeader($metaField->getLabel()) + ->withExtractor(function (ExportableItem $exportableItem) use ($metaField) { + return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue(); + }); + } + } + + /** @var array $columns */ + $columns = []; + + $rateColumns = ['currency', 'rate', 'internal_rate', 'hourly_rate', 'fixed_rate']; + + foreach ($template->getColumns($query) as $column) { + if ($column === 'date') { + $columns[$column] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin()); + } elseif ($column === 'begin') { + $columns[$column] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'end') { + $columns[$column] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'duration') { + $columns[$column] = (new Column('duration', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'duration_decimal') { + $columns[$column] = (new Column('duration', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'duration_seconds') { + $columns[$column] = (new Column('duration', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'break') { + // TODO remove method_exists with 3.0 + $columns[$column] = (new Column('break', $this->getFormatter('duration')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType + } elseif ($column === 'break_decimal') { + // TODO remove method_exists with 3.0 + $columns[$column] = (new Column('break', $this->getFormatter('duration_decimal')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType + } elseif ($column === 'break_seconds') { + // TODO remove method_exists with 3.0 + $columns[$column] = (new Column('break', $this->getFormatter('duration_seconds')))->withExtractor(fn (ExportableItem $exportableItem) => method_exists($exportableItem, 'getBreak') ? $exportableItem->getBreak() : 0)->withColumnWidth(ColumnWidth::SMALL); // @phpstan-ignore function.alreadyNarrowedType + } elseif ($column === 'currency' && $showRates) { + $columns[$column] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'rate' && $showRates) { + $columns[$column] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate()); + } elseif ($column === 'internal_rate' && $showRates) { + $columns[$column] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate()); + } elseif ($column === 'hourly_rate' && $showRates) { + $columns[$column] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate()); + } elseif ($column === 'fixed_rate' && $showRates) { + $columns[$column] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate()); + } elseif ($column === 'user.alias') { + $columns[$column] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'user.name') { + $columns[$column] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'user.email') { + $columns[$column] = (new Column('email', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getEmail())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'user.account_number') { + $columns[$column] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber()); + } elseif ($column === 'customer.name') { + $columns[$column] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'project.name') { + $columns[$column] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'activity.name') { + $columns[$column] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM); + } elseif ($column === 'description') { + $columns[$column] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE); + } elseif ($column === 'exported') { + $columns[$column] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isExported()); + } elseif ($column === 'billable') { + $columns[$column] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL); + } elseif ($column === 'tags') { + $columns[$column] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray()); + } elseif ($column === 'type') { + $columns[$column] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType()); + } elseif ($column === 'category') { + $columns[$column] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory()); + } elseif ($column === 'customer.number') { + $columns[$column] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber()); + } elseif ($column === 'project.number') { + $columns[$column] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber()); + } elseif ($column === 'activity.number') { + $columns[$column] = (new Column('activity_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getNumber()); + } elseif ($column === 'customer.vat_id') { + $columns[$column] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId()); + } elseif ($column === 'project.order_number') { + $columns[$column] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber()); + } elseif (str_starts_with($column, 'timesheet.meta.') && \array_key_exists($column, $timesheetMeta)) { + $columns[$column] = $timesheetMeta[$column]; + } elseif (str_starts_with($column, 'customer.meta.') && \array_key_exists($column, $customerMeta)) { + $columns[$column] = $customerMeta[$column]; + } elseif (str_starts_with($column, 'project.meta.') && \array_key_exists($column, $projectMeta)) { + $columns[$column] = $projectMeta[$column]; + } elseif (str_starts_with($column, 'activity.meta.') && \array_key_exists($column, $activityMeta)) { + $columns[$column] = $activityMeta[$column]; + } elseif (str_starts_with($column, 'user.meta.') && \array_key_exists($column, $userMeta)) { + $columns[$column] = $userMeta[$column]; + } else { + if ($this->logger !== null && ($showRates || !\in_array($column, $rateColumns, true))) { + $this->logger->warning(\sprintf('Unknown column "%s" used in exporter template "%s".', $column, $template->getTitle())); + } + } + } + + return $columns; + } +} diff --git a/src/Export/DefaultTemplate.php b/src/Export/DefaultTemplate.php new file mode 100644 index 00000000..c9606e8c --- /dev/null +++ b/src/Export/DefaultTemplate.php @@ -0,0 +1,145 @@ + + */ + public function getOptions(): array + { + return []; + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function getId(): string + { + return $this->id; + } + + public function getTitle(): string + { + return $this->title; + } + + /** + * @return MetaTableTypeInterface[] + */ + private function findMetaColumns(MetaDisplayEventInterface $event): array + { + $this->eventDispatcher->dispatch($event); + + return $event->getFields(); + } + + /** + * @return array + */ + public function getColumns(TimesheetQuery $query): array + { + // @deprecated from 2.36 - will be removed with 3.0 + $durationFormatter = 'duration'; + if (($user = $query->getCurrentUser()) instanceof User) { + $durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration'; + } + + $columns = [ + 'date', + 'begin', + 'end', + $durationFormatter, + 'currency', + 'rate', + 'internal_rate', + 'hourly_rate', + 'fixed_rate', + 'user.alias', + 'user.name', + 'user.email', + 'user.account_number', + 'customer.name', + 'project.name', + 'activity.name', + 'description', + 'billable', + 'tags', + 'type', + 'category', + 'customer.number', + 'project.number', + 'customer.vat_id', + 'project.order_number', + ]; + + foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $columns[] = 'timesheet.meta.' . $metaField->getName(); + } + } + + foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $columns[] = 'customer.meta.' . $metaField->getName(); + } + } + + foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $columns[] = 'project.meta.' . $metaField->getName(); + } + } + + foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) { + if ($metaField->getName() !== null) { + $columns[] = 'activity.meta.' . $metaField->getName(); + } + } + + $event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT); + $this->eventDispatcher->dispatch($event); + foreach ($event->getPreferences() as $metaField) { + if ($metaField->getName() !== null) { + $columns[] = 'user.meta.' . $metaField->getName(); + } + } + + return $columns; + } +} diff --git a/src/Export/ExportRendererInterface.php b/src/Export/ExportRendererInterface.php index ced546ae..72eb2417 100644 --- a/src/Export/ExportRendererInterface.php +++ b/src/Export/ExportRendererInterface.php @@ -13,6 +13,11 @@ use App\Entity\ExportableItem; use App\Repository\Query\TimesheetQuery; use Symfony\Component\HttpFoundation\Response; +/** + * TODO change interface for 3.0 + * @method getType() string + * @method isInternal() bool + */ interface ExportRendererInterface { /** diff --git a/src/Export/Renderer/CsvRendererFactory.php b/src/Export/Renderer/CsvRendererFactory.php index 48658654..f0dc25be 100644 --- a/src/Export/Renderer/CsvRendererFactory.php +++ b/src/Export/Renderer/CsvRendererFactory.php @@ -10,33 +10,33 @@ namespace App\Export\Renderer; use App\Export\Base\CsvRenderer; -use App\Export\Base\SpreadsheetRenderer; +use App\Export\ColumnConverter; +use App\Export\DefaultTemplate; use App\Export\TemplateInterface; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Log\LoggerInterface; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Translation\TranslatorInterface; final class CsvRendererFactory { public function __construct( - private readonly EventDispatcherInterface $dispatcher, - private readonly Security $voter, + private readonly ColumnConverter $converter, + private readonly EventDispatcherInterface $eventDispatcher, private readonly TranslatorInterface $translator, - private readonly ?LoggerInterface $logger = null, ) { } public function create(TemplateInterface $template): CsvRenderer { - $renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger); - $renderer->setTemplate($template); - - $renderer = new CsvRenderer($renderer, $this->translator); - $renderer->setId($template->getId()); - $renderer->setTitle($template->getTitle()); - $renderer->setLocale($template->getLocale()); + $renderer = new CsvRenderer($this->converter, $this->translator, $template); + $renderer->setInternal(true); return $renderer; } + + public function createDefault(): CsvRenderer + { + $template = new DefaultTemplate($this->eventDispatcher, 'csv'); + + return new CsvRenderer($this->converter, $this->translator, $template); + } } diff --git a/src/Export/Renderer/HtmlRendererFactory.php b/src/Export/Renderer/HtmlRendererFactory.php index 0b3a4c91..0f46396f 100644 --- a/src/Export/Renderer/HtmlRendererFactory.php +++ b/src/Export/Renderer/HtmlRendererFactory.php @@ -27,10 +27,14 @@ final class HtmlRendererFactory public function create(string $id, string $template): HtmlRenderer { - $renderer = new HtmlRenderer($this->twig, $this->dispatcher, $this->projectStatisticService, $this->activityStatisticService); - $renderer->setId($id); - $renderer->setTemplate($template); - - return $renderer; + return new HtmlRenderer( + $this->twig, + $this->dispatcher, + $this->projectStatisticService, + $this->activityStatisticService, + $id, + 'print', + $template + ); } } diff --git a/src/Export/Renderer/PdfRendererFactory.php b/src/Export/Renderer/PdfRendererFactory.php index e436dd2d..9e594141 100644 --- a/src/Export/Renderer/PdfRendererFactory.php +++ b/src/Export/Renderer/PdfRendererFactory.php @@ -10,8 +10,12 @@ namespace App\Export\Renderer; use App\Export\Base\PDFRenderer; +use App\Export\Base\PdfTemplateRenderer; +use App\Export\ColumnConverter; +use App\Export\Template; use App\Pdf\HtmlToPdfConverter; use App\Project\ProjectStatisticService; +use Symfony\Component\Translation\LocaleSwitcher; use Twig\Environment; final class PdfRendererFactory @@ -19,17 +23,33 @@ final class PdfRendererFactory public function __construct( private readonly Environment $twig, private readonly HtmlToPdfConverter $converter, - private readonly ProjectStatisticService $projectStatisticService + private readonly ProjectStatisticService $projectStatisticService, + private readonly LocaleSwitcher $localeSwitcher, + private readonly ColumnConverter $columnConverter ) { } - public function create(string $id, string $template): PDFRenderer + public function create(string $id, string $template, ?string $title = null): PDFRenderer { - $renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService); - $renderer->setId($id); - $renderer->setTitle($id); - $renderer->setTemplate($template); + return new PDFRenderer( + $this->twig, + $this->converter, + $this->projectStatisticService, + $id, + $title ?? $id, + $template, + ); + } - return $renderer; + public function createFromTemplate(Template $template): PdfTemplateRenderer + { + return new PdfTemplateRenderer( + $this->twig, + $this->converter, + $this->projectStatisticService, + $this->columnConverter, + $this->localeSwitcher, + $template + ); } } diff --git a/src/Export/Renderer/XlsxRendererFactory.php b/src/Export/Renderer/XlsxRendererFactory.php index f39a5654..b0eac301 100644 --- a/src/Export/Renderer/XlsxRendererFactory.php +++ b/src/Export/Renderer/XlsxRendererFactory.php @@ -9,33 +9,33 @@ namespace App\Export\Renderer; -use App\Export\Base\SpreadsheetRenderer; use App\Export\Base\XlsxRenderer; +use App\Export\ColumnConverter; +use App\Export\DefaultTemplate; use App\Export\TemplateInterface; use Psr\EventDispatcher\EventDispatcherInterface; -use Psr\Log\LoggerInterface; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Translation\TranslatorInterface; final class XlsxRendererFactory { public function __construct( - private readonly EventDispatcherInterface $dispatcher, - private readonly Security $voter, + private readonly ColumnConverter $converter, + private readonly EventDispatcherInterface $eventDispatcher, private readonly TranslatorInterface $translator, - private readonly ?LoggerInterface $logger = null, ) { } + public function createDefault(): XlsxRenderer + { + $template = new DefaultTemplate($this->eventDispatcher, 'xlsx'); + + return new XlsxRenderer($this->converter, $this->translator, $template); + } + public function create(TemplateInterface $template): XlsxRenderer { - $renderer = new SpreadsheetRenderer($this->dispatcher, $this->voter, $this->logger); - $renderer->setTemplate($template); - - $renderer = new XlsxRenderer($renderer, $this->translator); - $renderer->setId($template->getId()); - $renderer->setTitle($template->getTitle()); - $renderer->setLocale($template->getLocale()); + $renderer = new XlsxRenderer($this->converter, $this->translator, $template); + $renderer->setInternal(true); return $renderer; } diff --git a/src/Export/ServiceExport.php b/src/Export/ServiceExport.php index a6b60afd..9e59672e 100644 --- a/src/Export/ServiceExport.php +++ b/src/Export/ServiceExport.php @@ -31,7 +31,7 @@ final class ServiceExport */ private array $renderer = []; /** - * @var TimesheetExportInterface[] + * @var ExportRendererInterface[] */ private array $timesheetExporter = []; /** @@ -79,7 +79,12 @@ final class ServiceExport */ public function getRenderer(): array { - $renderer = []; + $renderer = [ + $this->csvRendererFactory->createDefault(), + $this->xlsxRendererFactory->createDefault(), + $this->pdfRendererFactory->create('pdf', 'export/pdf-layout.html.twig', 'default'), + $this->htmlRendererFactory->create('html', 'export/print.html.twig'), + ]; foreach ($this->exportTemplateRepository->findAll() as $template) { $tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type @@ -96,6 +101,10 @@ final class ServiceExport $renderer[] = $this->xlsxRendererFactory->create($tpl); break; + case 'pdf': + $renderer[] = $this->pdfRendererFactory->createFromTemplate($tpl); + break; + default: $this->logger->error('Unknown export template type: ' . $template->getRenderer()); break; @@ -115,7 +124,7 @@ final class ServiceExport continue; } - $renderer[] = $this->htmlRendererFactory->create($tplName, $tplName); + $renderer[] = $this->htmlRendererFactory->create($tplName, '@export/' . $tplName); } } @@ -127,7 +136,7 @@ final class ServiceExport continue; } - $renderer[] = $this->pdfRendererFactory->create($tplName, $tplName); + $renderer[] = $this->pdfRendererFactory->create($tplName, '@export/' . $tplName); } } } @@ -146,20 +155,27 @@ final class ServiceExport return null; } - public function addTimesheetExporter(TimesheetExportInterface $exporter): void + public function addTimesheetExporter(ExportRendererInterface $exporter): void { $this->timesheetExporter[] = $exporter; } /** - * @return TimesheetExportInterface[] + * @return ExportRendererInterface[] */ public function getTimesheetExporter(): array { - return $this->timesheetExporter; + $exporter = [ + $this->pdfRendererFactory->create('pdf', '@export/timesheet.pdf.twig'), + $this->xlsxRendererFactory->createDefault(), + $this->csvRendererFactory->createDefault(), + $this->htmlRendererFactory->create('print', 'timesheet/export.html.twig'), + ]; + + return array_merge($this->timesheetExporter, $exporter); } - public function getTimesheetExporterById(string $id): ?TimesheetExportInterface + public function getTimesheetExporterById(string $id): ?ExportRendererInterface { foreach ($this->getTimesheetExporter() as $exporter) { if ($exporter->getId() === $id) { diff --git a/src/Export/Template.php b/src/Export/Template.php index d7d8b1cf..3c7e260c 100644 --- a/src/Export/Template.php +++ b/src/Export/Template.php @@ -9,6 +9,8 @@ namespace App\Export; +use App\Repository\Query\TimesheetQuery; + final class Template implements TemplateInterface { private ?string $locale = null; @@ -72,7 +74,7 @@ final class Template implements TemplateInterface /** * @return array */ - public function getColumns(): array + public function getColumns(TimesheetQuery $query): array { return $this->columns; } diff --git a/src/Export/TemplateInterface.php b/src/Export/TemplateInterface.php index f603f16b..30fc9a6d 100644 --- a/src/Export/TemplateInterface.php +++ b/src/Export/TemplateInterface.php @@ -9,6 +9,8 @@ namespace App\Export; +use App\Repository\Query\TimesheetQuery; + interface TemplateInterface { public function getId(): string; @@ -18,7 +20,7 @@ interface TemplateInterface /** * @return array */ - public function getColumns(): array; + public function getColumns(TimesheetQuery $query): array; public function getLocale(): ?string; diff --git a/src/Export/Timesheet/HtmlRenderer.php b/src/Export/Timesheet/HtmlRenderer.php deleted file mode 100644 index 74c2900e..00000000 --- a/src/Export/Timesheet/HtmlRenderer.php +++ /dev/null @@ -1,26 +0,0 @@ -add('title', TextType::class); + $builder->add('title', TextType::class, ['label' => 'name']); $builder->add('renderer', ExportRendererType::class, ['label' => 'type']); $builder->add('language', LanguageType::class, ['required' => false]); $builder->add('columns', ExportColumnsType::class, ['required' => true]); - $builder->add('separator', ChoiceType::class, ['choices' => ['Comma (,)' => ',', 'Semicolon (;)' => ';'], 'required' => true]); + + $builder->add('separator', ChoiceType::class, [ + 'choices' => ['Comma (,)' => ',', 'Semicolon (;)' => ';'], + 'row_attr' => ['data-type' => 'csv'], + 'required' => true, + ]); + + $builder->add('name', TextType::class, [ + 'label' => 'title', + 'constraints' => [new Length(max: 100)], + 'attr' => ['maxlength' => 100], + 'row_attr' => ['data-type' => 'pdf'], + 'required' => false, + ]); + + $builder->add('summaryColumns', ExportSummaryColumnsType::class, [ + 'row_attr' => ['data-type' => 'pdf'], + 'required' => false, + ]); + + $builder->add('font', PdfFontType::class, [ + 'row_attr' => ['data-type' => 'pdf'], + 'required' => false, + ]); + + $builder->add('pageSize', ChoiceType::class, [ + 'label' => 'pageSize', + 'choices' => [ + 'A4' => 'A4', + 'A5' => 'A5', + 'A6' => 'A6', + 'Legal' => 'Legal', + 'Letter' => 'Letter', + ], + 'row_attr' => ['data-type' => 'pdf'], + 'required' => false, + ]); + + $builder->add('orientation', ChoiceType::class, [ + 'label' => 'orientation', + 'choices' => [ + 'portrait' => 'portrait', + 'landscape' => 'landscape', + ], + 'row_attr' => ['data-type' => 'pdf'], + 'required' => false, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/Type/ExportRendererType.php b/src/Form/Type/ExportRendererType.php index cdcaead5..c6770828 100644 --- a/src/Form/Type/ExportRendererType.php +++ b/src/Form/Type/ExportRendererType.php @@ -18,7 +18,11 @@ final class ExportRendererType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'choices' => ['button.csv' => 'csv', 'button.xlsx' => 'xlsx'], + 'choices' => [ + 'button.csv' => 'csv', + 'button.xlsx' => 'xlsx', + 'button.pdf' => 'pdf' + ], ]); } diff --git a/src/Form/Type/ExportSummaryColumnsType.php b/src/Form/Type/ExportSummaryColumnsType.php new file mode 100644 index 00000000..dc6f0fd9 --- /dev/null +++ b/src/Form/Type/ExportSummaryColumnsType.php @@ -0,0 +1,47 @@ +translator->trans('duration') . ' (1:30)' => 'duration', + $this->translator->trans('duration') . ' (1.5)' => 'duration_decimal', + 'rate' => 'rate', + 'internalRate' => 'internal_rate', + \sprintf('%s (%s)', $this->translator->trans('remaining_budget'), $this->translator->trans('budget')) => 'project_budget_money', + \sprintf('%s (%s)', $this->translator->trans('remaining_budget'), $this->translator->trans('timeBudget')) => 'project_budget_time', + ]; + + $resolver->setDefaults([ + 'choices' => $columns, + 'label' => 'export.summary', + 'multiple' => true, + ]); + } + + public function getParent(): string + { + return ChoiceType::class; + } +} diff --git a/src/Form/Type/PdfFontType.php b/src/Form/Type/PdfFontType.php new file mode 100644 index 00000000..dac6751a --- /dev/null +++ b/src/Form/Type/PdfFontType.php @@ -0,0 +1,48 @@ +setDefaults([ + 'choices' => $columns, + 'label' => 'font', + 'multiple' => false, + ]); + } + + public function getParent(): string + { + return ChoiceType::class; + } +} diff --git a/src/Pdf/PdfContext.php b/src/Pdf/PdfContext.php index 227b76d6..4ae802b1 100644 --- a/src/Pdf/PdfContext.php +++ b/src/Pdf/PdfContext.php @@ -17,17 +17,20 @@ final class PdfContext { private array $options = []; - public function setOption(string $key, string|array|null|bool $value): void + public function setOption(string $key, string|int|array|null|bool $value): void { $this->options[$key] = $value; } + /** + * @return array + */ public function getOptions(): array { return $this->options; } - public function getOption(string $key): array|string|null + public function getOption(string $key): string|int|array|null|bool { if (\array_key_exists($key, $this->options)) { return $this->options[$key]; diff --git a/src/Pdf/PdfRendererTrait.php b/src/Pdf/PdfRendererTrait.php index 4cd928dd..4cb325ac 100644 --- a/src/Pdf/PdfRendererTrait.php +++ b/src/Pdf/PdfRendererTrait.php @@ -25,7 +25,7 @@ trait PdfRendererTrait protected function createPdfResponse(string $content, PdfContext $context): Response { $filename = $context->getOption('filename'); - if (empty($filename)) { + if (!\is_string($filename) || empty($filename)) { throw new \Exception('Empty PDF filename given'); } $filename = FileHelper::convertToAsciiFilename($filename); diff --git a/src/Validator/Constraints/ExportRendererValidator.php b/src/Validator/Constraints/ExportRendererValidator.php index 112d2ea5..64528a1d 100644 --- a/src/Validator/Constraints/ExportRendererValidator.php +++ b/src/Validator/Constraints/ExportRendererValidator.php @@ -28,7 +28,7 @@ final class ExportRendererValidator extends ConstraintValidator return; } - $ids = ['csv', 'xlsx']; + $ids = ['csv', 'xlsx', 'pdf']; if (!\is_string($value) || !\in_array($value, $ids, true)) { $this->context->buildViolation(ExportRenderer::getErrorName(ExportRenderer::UNKNOWN_TYPE)) diff --git a/templates/export/index.html.twig b/templates/export/index.html.twig index c009898a..f80817ff 100644 --- a/templates/export/index.html.twig +++ b/templates/export/index.html.twig @@ -129,7 +129,8 @@ {% set btnTitle = type|trans %} {% endif %} {% if group|length == 1 %} - {% for id, title in group %} + {% for id, cfg in group %} + {% set title = cfg.title %} {% set btnTitle2 = ('button.' ~ title)|trans %} {% if btnTitle2 != ('button.' ~ title) %} {% set btnTitle = btnTitle2 %} @@ -144,7 +145,8 @@ {{ btnTitle }} {{ form_row(form.language) }} - {{ form_row(form.separator, {row_attr: {class: 'mb-3 export-type csv'}}) }} {{ form_row(form.columns) }} {{ form_rest(form) }} {% endblock %} @@ -26,9 +25,9 @@