Configurable PDF exports (#5641)
This commit is contained in:
@@ -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')) {
|
||||
|
||||
@@ -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<string> $columns
|
||||
*/
|
||||
public function setSummaryColumns(array $columns): void
|
||||
{
|
||||
$columns = \count($columns) > 0 ? implode(',', $columns) : null;
|
||||
|
||||
$this->setOption('summary_columns', $columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
91
src/Export/Base/AbstractSpreadsheetRenderer.php
Normal file
91
src/Export/Base/AbstractSpreadsheetRenderer.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
abstract class AbstractSpreadsheetRenderer
|
||||
{
|
||||
private bool $internal = false;
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return $this->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<Column> $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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ExportTrait
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
133
src/Export/Base/PdfTemplateRenderer.php
Normal file
133
src/Export/Base/PdfTemplateRenderer.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ColumnConverter;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Form\Type\PdfFontType;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Translation\LocaleSwitcher;
|
||||
use Twig\Environment;
|
||||
|
||||
#[Exclude]
|
||||
final class PdfTemplateRenderer implements DispositionInlineInterface, ExportRendererInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
use PDFRendererTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ColumnConverter $columnConverter,
|
||||
private readonly LocaleSwitcher $localeSwitcher,
|
||||
private readonly TemplateInterface $template
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function isInternal(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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<mixed>
|
||||
*/
|
||||
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<mixed>
|
||||
*/
|
||||
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<mixed>
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
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\Package\CellFormatter\ArrayFormatter;
|
||||
use App\Export\Package\CellFormatter\BooleanFormatter;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\CellFormatter\DateFormatter;
|
||||
use App\Export\Package\CellFormatter\DefaultFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationDecimalFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationFormatter;
|
||||
use App\Export\Package\CellFormatter\RateFormatter;
|
||||
use App\Export\Package\CellFormatter\TextFormatter;
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Export\Template;
|
||||
use App\Export\TemplateInterface;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @internal means no BC promise whatsoever!
|
||||
*/
|
||||
final class SpreadsheetRenderer
|
||||
{
|
||||
/**
|
||||
* @var array<string, CellFormatterInterface>
|
||||
*/
|
||||
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<Column>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
258
src/Export/ColumnConverter.php
Normal file
258
src/Export/ColumnConverter.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
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\Package\CellFormatter\ArrayFormatter;
|
||||
use App\Export\Package\CellFormatter\BooleanFormatter;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\CellFormatter\DateFormatter;
|
||||
use App\Export\Package\CellFormatter\DefaultFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationDecimalFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationFormatter;
|
||||
use App\Export\Package\CellFormatter\RateFormatter;
|
||||
use App\Export\Package\CellFormatter\TextFormatter;
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\ColumnWidth;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class ColumnConverter
|
||||
{
|
||||
/**
|
||||
* @var array<string, CellFormatterInterface>
|
||||
*/
|
||||
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<string, Column>
|
||||
*/
|
||||
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<string, Column> $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;
|
||||
}
|
||||
}
|
||||
145
src/Export/DefaultTemplate.php
Normal file
145
src/Export/DefaultTemplate.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\User;
|
||||
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\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Exclude;
|
||||
|
||||
#[Exclude]
|
||||
final class DefaultTemplate implements TemplateInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
private readonly string $id,
|
||||
private readonly ?string $locale = 'en',
|
||||
private readonly string $title = 'default',
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, string>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
public function getColumns(): array
|
||||
public function getColumns(TimesheetQuery $query): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
public function getColumns(): array;
|
||||
public function getColumns(TimesheetQuery $query): array;
|
||||
|
||||
public function getLocale(): ?string;
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Timesheet;
|
||||
|
||||
use App\Export\Base\HtmlRenderer as BaseHtmlRenderer;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
|
||||
final class HtmlRenderer extends BaseHtmlRenderer implements TimesheetExportInterface
|
||||
{
|
||||
protected function getTemplate(): string
|
||||
{
|
||||
return 'timesheet/export.html.twig';
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'print';
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,9 @@
|
||||
|
||||
namespace App\Export;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
#[AutoconfigureTag]
|
||||
interface TimesheetExportInterface
|
||||
interface TimesheetExportInterface extends ExportRendererInterface
|
||||
{
|
||||
/**
|
||||
* @param Timesheet[] $exportItems
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response;
|
||||
|
||||
public function getId(): string;
|
||||
|
||||
public function getTitle(): string;
|
||||
}
|
||||
|
||||
@@ -12,22 +12,71 @@ namespace App\Form;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Form\Type\ExportRendererType;
|
||||
use App\Form\Type\ExportSummaryColumnsType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use App\Form\Type\PdfFontType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
class ExportTemplateSpreadsheetForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->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
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
47
src/Form/Type/ExportSummaryColumnsType.php
Normal file
47
src/Form/Type/ExportSummaryColumnsType.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ExportSummaryColumnsType extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslatorInterface $translator,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$columns = [
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
48
src/Form/Type/PdfFontType.php
Normal file
48
src/Form/Type/PdfFontType.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class PdfFontType extends AbstractType
|
||||
{
|
||||
public const AVAILABLE_FONTS = [
|
||||
'times', 'serif', 'helvetica', 'sans', 'courier', 'monospace', 'mono', 'sans-serif',
|
||||
'dejavusanscondensed', 'dejavusans', 'dejavuserif', 'dejavuserifcondensed', 'dejavusansmono',
|
||||
'freesans', 'freeserif', ' freemono',
|
||||
// needed for: japanese + hebrew, cyrillic
|
||||
'sun-exta', 'unbatang'
|
||||
// deactivated for now, maybe later
|
||||
// 'ocrb', 'abyssinicasil', 'aboriginalsans', 'jomolhari', 'taiheritagepro', 'aegean', 'aegyptus', 'akkadian',
|
||||
// 'quivira', 'lannaalif', 'daibannasilbook', 'garuda', 'khmeros', 'dhyana', 'tharlon',
|
||||
// 'padaukbook', 'zawgyi-one', 'ayar', 'taameydavidclm', 'mph2bdamase', 'briyaz', 'lateef',
|
||||
];
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$columns = [];
|
||||
foreach (self::AVAILABLE_FONTS as $font) {
|
||||
$columns[ucfirst($font)] = $font;
|
||||
}
|
||||
|
||||
$resolver->setDefaults([
|
||||
'choices' => $columns,
|
||||
'label' => 'font',
|
||||
'multiple' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
@@ -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<string|int|array|null|bool>
|
||||
*/
|
||||
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];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user