Configurable PDF exports (#5641)

This commit is contained in:
Kevin Papst
2025-09-23 18:32:31 +02:00
committed by GitHub
parent 5d3e46cce2
commit 6d78c6ba36
107 changed files with 2428 additions and 1575 deletions

View File

@@ -24,6 +24,7 @@ services:
- '../src/Entity/'
- '../src/Event/'
- '../src/Export/Package/'
- '../src/Export/Base/'
- '../src/Form/Model/'
- '../src/Model/'
- '../src/Repository/Loader/'

View File

@@ -1518,121 +1518,11 @@ parameters:
count: 1
path: src/Export/Annotation/Order.php
-
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 5
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#"
count: 2
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\HtmlRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/HtmlRenderer.php
-
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 5
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method getTimeBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Cannot call method jsonSerialize\\(\\) on App\\\\Model\\\\Statistic\\\\BudgetStatistic\\|null\\.$#"
count: 2
path: src/Export/Base/PDFRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateActivityBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateProjectBudget\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:calculateSummary\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Method App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:getOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -1643,11 +1533,6 @@ parameters:
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#"
count: 1
path: src/Export/Base/PDFRenderer.php
-
message: "#^Property App\\\\Export\\\\Base\\\\PDFRenderer\\:\\:\\$pdfOptions type has no value type specified in iterable type array\\.$#"
count: 1
@@ -3098,11 +2983,6 @@ parameters:
count: 2
path: src/Invoice/Renderer/DocxRenderer.php
-
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, array\\|string given\\.$#"
count: 1
path: src/Invoice/Renderer/PdfRenderer.php
-
message: "#^Cannot call method getCustomer\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1

View File

@@ -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')) {

View File

@@ -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';

View File

@@ -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);

View 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();
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View 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);
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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);
}

View 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;
}
}

View 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;
}
}

View File

@@ -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
{
/**

View File

@@ -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);
}
}

View File

@@ -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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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'
],
]);
}

View 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;
}
}

View 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;
}
}

View File

@@ -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];

View File

@@ -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);

View File

@@ -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))

View File

@@ -129,7 +129,8 @@
{% set btnTitle = type|trans %}
{% endif %}
{% if group|length == 1 %}
{% for id, title in group %}
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle2 = ('button.' ~ title)|trans %}
{% if btnTitle2 != ('button.' ~ title) %}
{% set btnTitle = btnTitle2 %}
@@ -144,7 +145,8 @@
{{ btnTitle }}
</button>
<div class="dropdown-menu">
{% for id, title in group %}
{% for id, cfg in group %}
{% set title = cfg.title %}
{% set btnTitle = id|trans({}, 'export') %}
{% if btnTitle == id %}
{% set btnTitle = title|trans %}
@@ -154,7 +156,7 @@
{% endif %}
<a href="#" class="dropdown-item startExportBtn" data-type="{{ id }}">
{{ btnTitle }}
{% if allowCreate and loop.index0 != 0 and (type == 'csv' or type == 'xlsx') %}
{% if allowCreate and cfg.internal %}
<span class="dropdown-action modal-ajax-form open-edit ps-2 ms-auto" data-href="{{ path('export_template_edit', {exportTemplate: id}) }}">{{ icon('edit') }}</span>
<span class="dropdown-action api-link text-red dd-ts-trash text-danger ms-2" href="{{ path('delete_export_template', {id: id}) }}" data-event="kimai.exportTemplate" data-method="DELETE" data-question="confirm.delete" data-msg-error="action.delete.error" data-msg-success="action.delete.success">{{ icon('delete') }}</span>
{% endif %}

View File

@@ -0,0 +1,326 @@
<html lang="{{ locale ?? app.request.locale }}">
{% set title = (template.options.name ?? 'export.document_title')|trans %}
{# show the generation date of the PDF #}
{% set now = create_date('now', app.user) %}
{# internal variables to keep stats #}
{% set duration = 0 %}
{% set rate = 0 %}
{% set rateInternal = 0 %}
{% set currency = false %}
<head>
<title>{{ title }}</title>
<style>
{{ encore_entry_css_source('export-pdf')|raw }}
body { font-family: {{ font }} !important; }
</style>
</head>
<body>
<!--mpdf
<htmlpageheader name="header">
<table style="font-size: 10pt; width: 100%; border-bottom: 1px solid #000000" cellspacing="0">
<tr>
<td style="text-align: left; padding-left: 0; font-weight: bold;">
{{ title }}
</td>
<td style="text-align: right; padding-right: 0;">
{% if query is defined %}
{{ 'export.period'|trans }}:
{{ query.begin|date_short }} &ndash; {{ query.end|date_short }}
{% endif %}
</td>
</tr>
</table>
</htmlpageheader>
<sethtmlpageheader name="header" page="ALL" value="on" show-this-page="1" />
<htmlpagefooter name="myfooter">
<table style="border-top: 1px solid #000000; font-size: 9pt; padding-top: 3mm; width: 100%" cellspacing="0">
<tr>
<td style="text-align: left; padding-left: 0;">
{{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }}
</td>
<td style="text-align: right; padding-right: 0;">
{% set company = config('theme.branding.company') %}
{% if company is not empty %}
{{ company }} &ndash; {{ now|date_time }}
{% else %}
{{ 'export.date_copyright'|trans({'%date%': now|date_time, '%kimai%': '<a href="' ~ constant('App\\Constants::HOMEPAGE') ~ '">' ~ constant('App\\Constants::SOFTWARE') ~ '</a>'})|raw }}
{% endif %}
</td>
</tr>
</table>
</htmlpagefooter>
<sethtmlpagefooter name="myfooter" value="on" />
mpdf-->
<div style="margin-top: 120px;">
{# Summary only if passed into template #}
{% if summary|length > 0 %}
{% set summaryColumns = ['customer', 'project'] %}
{% for column in summary %}
{% if column == 'project_budget_time' %}
{% set summaryColumns = summaryColumns|merge(['timeBudget']) %}
{% elseif column == 'project_budget_money' %}
{% set summaryColumns = summaryColumns|merge(['budget']) %}
{% elseif column == 'duration' %}
{% set summaryColumns = summaryColumns|merge(['duration']) %}
{% elseif column == 'internal_rate' %}
{% set summaryColumns = summaryColumns|merge(['internalRate']) %}
{% elseif column == 'duration_decimal' %}
{% set summaryColumns = summaryColumns|merge(['duration_decimal']) %}
{% elseif column == 'rate' %}
{% set summaryColumns = summaryColumns|merge(['rate']) %}
{% endif %}
{% endfor %}
<h3>{{ 'export.summary'|trans }}</h3>
<table class="items">
<thead>
<tr>
{% for summaryColumn in summaryColumns %}
{% if summaryColumn == 'duration_decimal' %}
<th class="text-left">{{ 'duration'|trans }}</th>
{% elseif summaryColumn == 'budget' %}
<th>{{ 'remaining_budget'|trans }}</th>
{% elseif summaryColumn == 'timeBudget' %}
<th>{{ 'remaining_budget'|trans }}</th>
{% elseif summaryColumn == 'customer' or summaryColumn == 'project' %}
<th>{{ summaryColumn|trans }}</th>
{% else %}
<th class="center">{{ summaryColumn|trans }}</th>
{% endif %}
{% endfor %}
</tr>
</thead>
<tbody>
{% set showCustomerSummary = true %}
{% set showTotalSummary = true %}
{% set customer = null %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% set customerInternalRate = 0 %}
{% set customerCurrency = null %}
{% set customerCount = 0 %}
{% set multiCurrency = false %}
{% set totalDuration = 0 %}
{% set totalInternalRate = 0 %}
{% set totalRate = 0 %}
{% for id, summary in summaries %}
<!-- CONTENT_PART -->
{% set totalDuration = totalDuration + summary.duration %}
{% set totalInternalRate = totalInternalRate + summary.rate_internal %}
{% set totalRate = totalRate + summary.rate %}
{% if customerCurrency is not null and customerCurrency is not same as(summary.currency) %}
{% set multiCurrency = true %}
{% endif %}
{% if customer is same as(null) %}
{% set customer = summary.customer %}
{% set customerCurrency = summary.currency %}
{% endif %}
{% if customer is not same as(summary.customer) %}
<tr class="summary">
{% for summaryColumn in summaryColumns %}
{% if summaryColumn == 'duration' %}
<td class="totals duration">{{ customerDuration|duration }}</td>
{% elseif summaryColumn == 'duration_decimal' %}
<td class="totals duration">{{ customerDuration|duration(true) }}</td>
{% elseif summaryColumn == 'internalRate' %}
<td class="totals cost">{{ customerInternalRate|money(customerCurrency) }}</td>
{% elseif summaryColumn == 'rate' %}
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% set customerCurrency = summary.currency %}
{% set customer = summary.customer %}
{% set customerDuration = 0 %}
{% set customerRate = 0 %}
{% set customerInternalRate = 0 %}
{% set customerCount = 0 %}
{% endif %}
<tr class="{{ cycle(['odd', 'even'], customerCount) }}">
{% for summaryColumn in summaryColumns %}
{% if summaryColumn == 'customer' %}
<td>{{ summary.customer }}</td>
{% elseif summaryColumn == 'project' %}
<td>{{ summary.project }}</td>
{% elseif summaryColumn == 'timeBudget' %}
<td class="center">
{% if budgets[id] is defined and budgets[id].time_left > 0 %}
{{ budgets[id].time_left|duration }}
{% endif %}
</td>
{% elseif summaryColumn == 'budget' %}
<td class="center">
{% if budgets[id] is defined and budgets[id].money_left > 0 %}
{{ budgets[id].money_left|money(summary.currency) }}
{% endif %}
</td>
{% elseif summaryColumn == 'hourlyRate' %}
{% set summaryHourlyRate = summary.duration/3600 %}
<td class="cost">{{ summaryHourlyRate > 0 ? (summary.rate/summaryHourlyRate)|money(summary.currency) : '' }}</td>
{% elseif summaryColumn == 'duration' %}
<td class="duration">{{ summary.duration|duration }}</td>
{% elseif summaryColumn == 'duration_decimal' %}
<td class="duration">{{ summary.duration|duration(true) }}</td>
{% elseif summaryColumn == 'internalRate' %}
<td class="cost">{{ summary.rate_internal|money(summary.currency) }}</td>
{% elseif summaryColumn == 'rate' %}
<td class="cost">{{ summary.rate|money(summary.currency) }}</td>
{% endif %}
{% endfor %}
</tr>
{% set customerDuration = customerDuration + summary.duration %}
{% set customerRate = customerRate + summary.rate %}
{% set customerInternalRate = customerInternalRate + summary.rate_internal %}
{% set customerCount = customerCount + 1 %}
{% endfor %}
{% if showCustomerSummary and customer is not same as(null) %}
<tr class="summary">
{% for summaryColumn in summaryColumns %}
{% if summaryColumn == 'duration' %}
<td class="totals duration">{{ customerDuration|duration }}</td>
{% elseif summaryColumn == 'duration_decimal' %}
<td class="totals duration">{{ customerDuration|duration(true) }}</td>
{% elseif summaryColumn == 'internalRate' %}
<td class="totals cost">{{ customerInternalRate|money(customerCurrency) }}</td>
{% elseif summaryColumn == 'rate' %}
<td class="totals cost">{{ customerRate|money(customerCurrency) }}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% endif %}
{% if showTotalSummary %}
<tr class="{% if not showCustomerSummary %}summary{% else %}summary-total{% endif %}">
<td class="totals" colspan="2">
{{ 'sum.total'|trans }}
</td>
{% for summaryColumn in summaryColumns %}
{% if summaryColumn == 'duration' %}
<td class="totals duration">{{ totalDuration|duration }}</td>
{% elseif summaryColumn == 'duration_decimal' %}
<td class="totals duration">{{ totalDuration|duration(true) }}</td>
{% elseif summaryColumn == 'internalRate' %}
<td class="totals cost">
{% if not multiCurrency %}
{{ totalInternalRate|money(customerCurrency) }}
{% endif %}
</td>
{% elseif summaryColumn == 'rate' %}
<td class="totals cost">
{% if not multiCurrency %}
{{ totalRate|money(customerCurrency) }}
{% endif %}
</td>
{% elseif summaryColumn not in ['customer', 'project'] %}
<td></td>
{% endif %}
{% endfor %}
</tr>
{% endif %}
</tbody>
</table>
<pagebreak/>
{% endif %}
{# the list of all export items follows below #}
<h3>{{ 'export.full_list'|trans }}</h3>
{% set replace_classes = {
'hourlyRate': 'center',
'internalRate': 'center',
'fixedRate': 'center',
'rate': 'center',
'duration': 'center',
'break': 'center',
} %}
<table class="items">
<thead>
<tr>
{% for column in columns %}
<th{% if replace_classes[column.name] is defined %} class="{{ replace_classes[column.name] }}"{% endif %}>
{{ column.header|trans }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for entry in entries %}
<!-- CONTENT_PART -->
{% set duration = duration + entry.duration %}
{% set entryCurrency = entry.project.customer.currency %}
{% if currency is same as(false) %}
{% set currency = entryCurrency %}
{% endif %}
{% if currency is not same as(entryCurrency) %}
{% set currency = null %}
{% endif %}
{# no check for is_granted('view_rate', entry) because it is only available for timesheets,
but maybe missing for other potential export repositories #}
{% set rate = rate + entry.rate %}
{% set rateInternal = rateInternal + entry.internalRate %}
<tr class="{{ cycle(['odd', 'even'], loop.index0) }}">
{% for id, col in columns %}
{% set column = col.name %}
{% set value = col.extract(entry) %}
{% if column == 'date' %}
<td class="text-nowrap">{{ value|date_short }}</td>
{% elseif column == 'begin' %}
<td>{{ value|time }}</td>
{% elseif column == 'end' %}
<td>{{ value|time }}</td>
{% elseif column == 'user' %}
<td>{{ value }}</td>
{% elseif column == 'currency' %}
<td>{{ entryCurrency }}</td>
{% elseif column == 'description' %}
<td>{{ value|desc2html }}</td>
{% elseif id == 'hourly_rate' %}
<td class="cost">
{% if entry.fixedRate %}
-
{% else %}
{{ value|money(entryCurrency) }}
{% endif %}
</td>
{% elseif id in ['duration', 'duration_decimal', 'duration_seconds'] %}
<td class="duration">{{ value|duration(id == 'duration_decimal') }}</td>
{% elseif id in ['break', 'break_decimal', 'break_seconds'] %}
<td class="duration">{{ value|duration(id == 'break_decimal') }}</td>
{% elseif id == 'internal_rate' %}
<td class="cost">{{ value|money(entryCurrency) }}</td>
{% elseif id == 'rate' %}
<td class="cost">{{ value|money(entryCurrency) }}</td>
{% elseif id == 'exported' or id == 'billable' %}
<td>{{ value is same as (false) ? ('no'|trans) : ('yes'|trans) }}</td>
{% elseif column == 'tags' %}
<td>{{ value|join(', ') }}</td>
{% else %}
<td>{{ value }}</td>
{% endif %}
{% endfor %}
</tr>
{% endfor %}
<tr class="summary">
{% for id, column in columns %}
{% if id in ['duration', 'duration_decimal', 'duration_seconds'] %}
<td class="totals duration">{{ duration|duration(id == 'duration_decimal') }}</td>
{% elseif id == 'internal_rate' %}
<td class="totals cost">3{% if currency is not null %}{{ rateInternal|money(currency) }}{% endif %}</td>
{% elseif id == 'rate' %}
<td class="totals cost">4{% if currency is not null %}{{ rate|money(currency) }}{% endif %}</td>
{% else %}
<td></td>
{% endif %}
{% endfor %}
</tr>
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -1,11 +0,0 @@
{% extends 'export/pdf-layout.html.twig' %}
{% set showRateBudget = false %}
{% set showTimeBudget = false %}
{% for budget in budgets %}
{% if budget.money_left > 0 %}
{% set showRateBudget = true %}
{% endif %}
{% if budget.time_left > 0 %}
{% set showTimeBudget = true %}
{% endif %}
{% endfor %}

View File

@@ -1,2 +0,0 @@
{% extends 'export/pdf-layout.html.twig' %}
{% set showInternalRate = true %}

View File

@@ -1 +0,0 @@
{% extends 'export/pdf-layout.html.twig' %}

View File

@@ -18,7 +18,6 @@
</div>
</div>
{{ form_row(form.language) }}
{{ form_row(form.separator, {row_attr: {class: 'mb-3 export-type csv'}}) }}
{{ form_row(form.columns) }}
{{ form_rest(form) }}
{% endblock %}
@@ -26,9 +25,9 @@
<script>
function changeExportTemplateType(typeElement)
{
typeElement.form.querySelectorAll('.export-type').forEach(
typeElement.form.querySelectorAll('[data-type]').forEach(
element => {
if (element.classList.contains(typeElement.value)) {
if (element.dataset['type'] !== undefined && element.dataset['type'] === typeElement.value) {
element.classList.remove('d-none');
} else {
element.classList.add('d-none');

View File

@@ -105,10 +105,8 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'default.html.twig' => 'default.html.twig',
'default-budget.pdf.twig' => 'default-budget.pdf.twig',
'default-internal.pdf.twig' => 'default-internal.pdf.twig',
'default.pdf.twig' => 'default.pdf.twig',
'html' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
$node = $client->getCrawler()->filter('#export-buttons .startExportBtn');
@@ -164,10 +162,8 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// assert export type buttons are available
$expected = [
'csv' => 'csv',
'default.html.twig' => 'default.html.twig',
'default-budget.pdf.twig' => 'default-budget.pdf.twig',
'default-internal.pdf.twig' => 'default-internal.pdf.twig',
'default.pdf.twig' => 'default.pdf.twig',
'html' => 'html',
'pdf' => 'pdf',
'xlsx' => 'xlsx'
];
$node = $client->getCrawler()->filter('#export-buttons .startExportBtn');
@@ -231,7 +227,7 @@ class ExportControllerTest extends AbstractControllerBaseTestCase
// don't add daterange to make sure the current month is the default range
$client->submit($form, [
'renderer' => 'default.html.twig',
'renderer' => 'html',
'markAsExported' => 1
]);

View File

@@ -24,6 +24,12 @@ class ExportTemplateTest extends AbstractEntityTestCase
self::assertNull($sut->getLanguage());
self::assertEquals([], $sut->getColumns());
self::assertEquals([], $sut->getOptions());
self::assertNull($sut->getName());
self::assertNull($sut->getFont());
self::assertNull($sut->getOrientation());
self::assertNull($sut->getPageSize());
self::assertEquals(',', $sut->getSeparator());
self::assertEquals([], $sut->getSummaryColumns());
}
public function testSetter(): void
@@ -51,13 +57,24 @@ class ExportTemplateTest extends AbstractEntityTestCase
$sut->setColumns(null);
self::assertEquals([], $sut->getColumns());
$sut->setName('my name is funny');
$sut->setFont('Helvetica');
$sut->setPageSize('Letter');
$sut->setSummaryColumns(['customer', 'rate', 'duration_decimal']);
self::assertEquals('my name is funny', $sut->getName());
self::assertEquals('Helvetica', $sut->getFont());
self::assertEquals('Letter', $sut->getPageSize());
self::assertEquals(['customer', 'rate', 'duration_decimal'], $sut->getSummaryColumns());
$sut->setOptions(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO']);
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO'], $sut->getOptions());
$sut->setOption('empty', 123);
self::assertEquals(['foo' => 1, 'bar' => true, 'WORLD' => 'HELLO', 'empty' => 123], $sut->getOptions());
$sut->setOption('foo', 4711);
$sut->setOption('empty', null);
$sut->setOption('bar', false);
$sut->setOption('hello', 'kimai');
self::assertEquals(['foo' => 4711, 'bar' => false, 'WORLD' => 'HELLO', 'empty' => null, 'hello' => 'kimai'], $sut->getOptions());
self::assertEquals(['foo' => 4711, 'bar' => false, 'WORLD' => 'HELLO', 'hello' => 'kimai'], $sut->getOptions());
$sut->setOptions(null);
self::assertEquals([], $sut->getOptions());
}
@@ -75,6 +92,21 @@ class ExportTemplateTest extends AbstractEntityTestCase
$sut->setSeparator('.');
}
public function testSetOrientation(): void
{
$sut = new ExportTemplate();
self::assertNull($sut->getOrientation());
$sut->setOrientation('landscape');
self::assertEquals('landscape', $sut->getOrientation());
$sut->setOrientation('PORTRAIT');
self::assertEquals('portrait', $sut->getOrientation());
$sut->setOrientation('LandScapE');
self::assertEquals('landscape', $sut->getOrientation());
$this->expectException(\InvalidArgumentException::class);
$sut->setOrientation('vertical');
}
public function testClone(): void
{
$sut = new ExportTemplate();

View File

@@ -9,37 +9,31 @@
namespace App\Tests\Export\Base;
use App\Entity\User;
use App\Export\Base\AbstractSpreadsheetRenderer;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\ColumnConverter;
use App\Export\DefaultTemplate;
use App\Export\Package\SpoutSpreadsheet;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Base\RendererTrait
*/
#[CoversClass(CsvRenderer::class)]
#[CoversClass(SpreadsheetRenderer::class)]
#[CoversClass(AbstractSpreadsheetRenderer::class)]
#[CoversClass(SpoutSpreadsheet::class)]
#[CoversClass(CsvRenderer::class)]
#[Group('integration')]
class CsvRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(bool $exportDecimal = false): CsvRenderer
protected function getAbstractRenderer(?string $locale): CsvRenderer
{
$user = $this->createMock(User::class);
$user->expects($this->any())->method('isExportDecimal')->willReturn($exportDecimal);
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn($user);
$security->expects($this->any())->method('getUser')->willReturn(null);
$security->expects($this->any())->method('isGranted')->willReturn(true);
$translator = $this->getContainer()->get(TranslatorInterface::class);
@@ -48,21 +42,23 @@ class CsvRendererTest extends AbstractRendererTestCase
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new CsvRenderer(new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class)), $translator);
$converter = new ColumnConverter($dispatcher, $security);
$template = new DefaultTemplate($dispatcher, 'csv', $locale);
return new CsvRenderer($converter, $translator, $template);
}
public function testConfiguration(): void
public function testConfigurationFromTemplate(): void
{
$sut = $this->getAbstractRenderer();
$sut = $this->getAbstractRenderer('en');
self::assertEquals('csv', $sut->getType());
self::assertEquals('csv', $sut->getId());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertFalse($sut->isInternal());
$sut->setInternal(true);
self::assertTrue($sut->isInternal());
}
public static function getTestModel(): array
@@ -89,11 +85,10 @@ class CsvRendererTest extends AbstractRendererTestCase
#[DataProvider('getTestModel')]
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3, bool $exportDecimal, ?string $locale, array $header): void
{
$sut = $this->getAbstractRenderer($exportDecimal);
$sut->setLocale($locale);
$sut = $this->getAbstractRenderer($locale);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$response = $this->render($sut, $exportDecimal);
self::assertInstanceOf(BinaryFileResponse::class, $response);
$file = $response->getFile();
$prefix = date('Ymd');
@@ -120,10 +115,10 @@ class CsvRendererTest extends AbstractRendererTestCase
self::assertFalse(file_exists($file->getRealPath()));
$all = [];
$rows = str_getcsv($content2, PHP_EOL);
$rows = array_filter(explode(PHP_EOL, $content2), function (string $line) { return $line !== ''; });
foreach ($rows as $row) {
self::assertIsString($row);
$all[] = str_getcsv($row);
$all[] = str_getcsv($row, ',', '"', '\\');
}
self::assertEquals($header, $all[0]);

View File

@@ -0,0 +1,73 @@
<?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\Tests\Export\Base;
use App\Activity\ActivityStatisticService;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\RendererTrait;
use App\Project\ProjectStatisticService;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
#[CoversClass(RendererTrait::class)]
#[CoversClass(HtmlRenderer::class)]
#[Group('integration')]
class HtmlRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(): HtmlRenderer
{
return new HtmlRenderer(
$this->createMock(Environment::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class),
'foo',
'bar',
'export/print.html.twig'
);
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
self::assertEquals('foo', $sut->getId());
self::assertEquals('bar', $sut->getTitle());
self::assertEquals('html', $sut->getType());
self::assertFalse($sut->isInternal());
}
/**
* @group legacy
*/
public function testLegacy(): void
{
$sut = $this->getAbstractRenderer();
$sut->setTemplate('some'); // @phpstan-ignore method.deprecated
$sut->setId('xxxxxx'); // @phpstan-ignore method.deprecated
self::assertEquals('xxxxxx', $sut->getId());
}
public function testRender(): void
{
$sut = $this->getAbstractRenderer();
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);
$content = $response->getContent();
self::assertIsString($content);
}
}

View File

@@ -10,17 +10,18 @@
namespace App\Tests\Export\Base;
use App\Export\Base\PDFRenderer;
use App\Export\Base\RendererTrait;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
/**
* @covers \App\Export\Base\RendererTrait
* @covers \App\Pdf\PdfRendererTrait
*/
#[CoversClass(RendererTrait::class)]
#[CoversClass(PdfRendererTrait::class)]
#[CoversClass(PDFRenderer::class)]
#[Group('integration')]
class PdfRendererTest extends AbstractRendererTestCase
@@ -31,22 +32,22 @@ class PdfRendererTest extends AbstractRendererTestCase
$converter = $this->createMock(HtmlToPdfConverter::class);
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
return new PDFRenderer($twig, $converter, $projectStatisticService);
return new PDFRenderer(
$twig,
$converter,
$projectStatisticService,
'foo',
'bar',
'export/print.html.twig'
);
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer();
self::assertEquals('pdf', $sut->getId());
self::assertEquals('pdf', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertEquals('foo', $sut->getId());
self::assertEquals('bar', $sut->getTitle());
self::assertEquals([], $sut->getPdfOptions());
$sut->setPdfOption('foo', 'bar');
self::assertEquals(['foo' => 'bar'], $sut->getPdfOptions());
@@ -54,5 +55,33 @@ class PdfRendererTest extends AbstractRendererTestCase
self::assertEquals(['foo' => 'bar2'], $sut->getPdfOptions());
$sut->setPdfOption('hello', 'world');
self::assertEquals(['foo' => 'bar2', 'hello' => 'world'], $sut->getPdfOptions());
self::assertFalse($sut->isInternal());
}
/**
* @group legacy
*/
public function testLegacy(): void
{
$sut = $this->getAbstractRenderer();
$sut->setTemplate('some'); // @phpstan-ignore method.deprecated
$sut->setTitle('xxxxxx'); // @phpstan-ignore method.deprecated
self::assertEquals('xxxxxx', $sut->getTitle());
}
public function testRender(): void
{
$sut = $this->getAbstractRenderer();
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);
$prefix = date('Ymd');
self::assertEquals('application/pdf', $response->headers->get('Content-Type'));
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.pdf', $response->headers->get('Content-Disposition'));
$content = $response->getContent();
self::assertIsString($content);
}
}

View File

@@ -0,0 +1,193 @@
<?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\Tests\Export\Base;
use App\Entity\User;
use App\Export\Base\PdfTemplateRenderer;
use App\Export\Base\RendererTrait;
use App\Export\ColumnConverter;
use App\Export\DefaultTemplate;
use App\Export\Template;
use App\Export\TemplateInterface;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Translation\LocaleSwitcher;
use Twig\Environment;
#[CoversClass(PdfRendererTrait::class)]
#[CoversClass(RendererTrait::class)]
#[CoversClass(PdfTemplateRenderer::class)]
#[Group('integration')]
class PdfTemplateRendererTest extends AbstractRendererTestCase
{
protected function getAbstractRenderer(Environment $twig, ?TemplateInterface $template = null): PdfTemplateRenderer
{
$htmlConverter = $this->createMock(HtmlToPdfConverter::class);
$projectStatisticService = $this->createMock(ProjectStatisticService::class);
$security = $this->createMock(Security::class);
$security->expects($this->any())->method('getUser')->willReturn(new User());
$security->expects($this->any())->method('isGranted')->willReturn(true);
$security->expects($this->any())->method('isGranted')->willReturn(true);
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
if ($template === null) {
$template = new DefaultTemplate($dispatcher, 'test', 'en', 'bar');
}
$converter = new ColumnConverter($dispatcher, $security);
return new PdfTemplateRenderer(
$twig,
$htmlConverter,
$projectStatisticService,
$converter,
$this->createMock(LocaleSwitcher::class),
$template
);
}
public function testConfiguration(): void
{
$sut = $this->getAbstractRenderer($this->createMock(Environment::class));
self::assertEquals('test', $sut->getId());
self::assertEquals('bar', $sut->getTitle());
self::assertEquals('pdf', $sut->getType());
self::assertTrue($sut->isInternal());
}
public function testDefaultTemplateRender(): void
{
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())->method('render')->willReturnCallback(function (string $templateName, array $options) {
if ($templateName !== 'export/renderer.pdf.twig') {
$this->fail('Wrong template given, expected "export/renderer.pdf.twig"');
}
self::assertArrayHasKey('font', $options);
self::assertArrayHasKey('columns', $options);
self::assertArrayHasKey('summary', $options);
self::assertArrayHasKey('template', $options);
self::assertArrayHasKey('title', $options);
self::assertArrayHasKey('locale', $options);
self::assertArrayHasKey('entries', $options);
self::assertArrayHasKey('query', $options);
self::assertEquals('sans-serif', $options['font']);
self::assertEquals([
'date',
'begin',
'end',
'duration',
'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',
'timesheet.meta.foo',
'timesheet.meta.foo2',
'customer.meta.customer-foo',
'project.meta.project-foo',
'project.meta.project-foo2',
'activity.meta.activity-foo',
'user.meta.mypref',
], array_keys($options['columns']));
self::assertEquals([], $options['summary']);
self::assertNull($options['title']);
self::assertEquals('en', $options['locale']);
self::assertIsArray($options['entries']);
self::assertInstanceOf(TimesheetQuery::class, $options['query']);
return '';
});
$sut = $this->getAbstractRenderer($twig);
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);
}
public function testRender(): void
{
$template = new Template('test_id', 'My title');
$template->setLocale('de');
$template->setOptions([
'summary_columns' => 'duration,rate,project_budget_money',
'orientation' => 'landscape',
'pageSize' => 'Letter',
'font' => 'freesans',
'name' => 'My title 2',
]);
$twig = $this->createMock(Environment::class);
$twig->expects(self::once())->method('render')->willReturnCallback(function (string $templateName, array $options) use ($template) {
if ($templateName !== 'export/renderer.pdf.twig') {
$this->fail('Wrong template given, expected "export/renderer.pdf.twig"');
}
self::assertArrayHasKey('font', $options);
self::assertArrayHasKey('columns', $options);
self::assertArrayHasKey('summary', $options);
self::assertArrayHasKey('template', $options);
self::assertArrayHasKey('title', $options);
self::assertArrayHasKey('locale', $options);
self::assertArrayHasKey('entries', $options);
self::assertArrayHasKey('query', $options);
self::assertEquals('freesans', $options['font']);
self::assertEquals([], $options['columns']);
self::assertEquals(['duration', 'rate', 'project_budget_money'], $options['summary']);
self::assertSame($options['template'], $template);
self::assertEquals('My title 2', $options['title']);
self::assertEquals('de', $options['locale']);
self::assertIsArray($options['entries']);
self::assertInstanceOf(TimesheetQuery::class, $options['query']);
return '';
});
$sut = $this->getAbstractRenderer($twig, $template);
$response = $this->render($sut);
self::assertInstanceOf(Response::class, $response);
$prefix = date('Ymd');
self::assertEquals('application/pdf', $response->headers->get('Content-Type'));
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.pdf', $response->headers->get('Content-Disposition'));
$content = $response->getContent();
self::assertIsString($content);
}
}

View File

@@ -1,193 +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\Tests\Export\Base;
use App\Entity\ExportableItem;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Package\SpreadsheetPackage;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Export\Package\MemoryPackage;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Contracts\Translation\TranslatorInterface;
#[CoversClass(SpreadsheetRenderer::class)]
#[Group('integration')]
class SpreadsheetRendererTest extends AbstractRendererTestCase
{
public function testWriteSpreadsheetCreatesSpreadsheetWithCorrectHeaders(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::once())->method('setColumns');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
}
public function testWriteSpreadsheetAddsRowsForExportItems(): void
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::exactly(2))->method('addRow');
$spreadsheetPackage->expects(self::once())->method('save');
$exportItem = $this->createMock(ExportableItem::class);
$exportItem->method('getBegin')->willReturn(new \DateTime());
$exportItem->method('getEnd')->willReturn(new \DateTime());
$exportItem->method('getDuration')->willReturn(3600);
$exportItem->method('getRate')->willReturn(100.0);
$exportItem->method('getInternalRate')->willReturn(80.0);
$exportItem->method('getHourlyRate')->willReturn(50.0);
$exportItem->method('getFixedRate')->willReturn(200.0);
$exportItem->method('getUser')->willReturn(null);
$exportItem->method('getProject')->willReturn(null);
$exportItem->method('getActivity')->willReturn(null);
$exportItem->method('getDescription')->willReturn('Test description');
$exportItem->method('isBillable')->willReturn(true);
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
}
public function testWriteSpreadsheetAddsTotalRowWhenMoreThanOneRow(): void
{
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturnArgument(0);
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$security = $this->createMock(Security::class);
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
$spreadsheetPackage->expects(self::exactly(3))->method('addRow');
$exportItem = $this->createMock(ExportableItem::class);
$exportItem->method('getBegin')->willReturn(new \DateTime());
$exportItem->method('getEnd')->willReturn(new \DateTime());
$exportItem->method('getDuration')->willReturn(3600);
$exportItem->method('getRate')->willReturn(100.0);
$exportItem->method('getInternalRate')->willReturn(80.0);
$exportItem->method('getHourlyRate')->willReturn(50.0);
$exportItem->method('getFixedRate')->willReturn(200.0);
$exportItem->method('getUser')->willReturn(null);
$exportItem->method('getProject')->willReturn(null);
$exportItem->method('getActivity')->willReturn(null);
$exportItem->method('getDescription')->willReturn('Test description');
$exportItem->method('isBillable')->willReturn(true);
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
}
public static function getTestData(): iterable
{
yield [null, [
'date' => 'date',
'begin' => 'begin',
'end' => 'end',
'duration' => 'duration',
'currency' => 'currency',
'rate' => 'rate',
'internalRate' => 'internalRate',
'hourlyRate' => 'hourlyRate',
'fixedRate' => 'fixedRate',
'alias' => 'alias',
'username' => 'username',
'account_number' => 'account_number',
'customer' => 'customer',
'project' => 'project',
'activity' => 'activity',
'description' => 'description',
'billable' => 'billable',
'tags' => 'tags',
'type' => 'type',
'category' => 'category',
'number' => 'number',
'project_number' => 'project_number',
'vat_id' => 'vat_id',
'orderNumber' => 'orderNumber',
'timesheet.meta.foo' => 'Working place',
'timesheet.meta.foo2' => 'Working place',
'customer.meta.customer-foo' => 'Working place',
'project.meta.project-foo' => 'Working place',
'project.meta.project-foo2' => 'Working place',
'activity.meta.activity-foo' => 'Working place',
'user.meta.mypref' => 'mypref',
'email' => 'email'
]];
$template = new Template('test', 'Testing');
$template->setLocale('de');
$template->setColumns(['date', 'user.name', 'duration_decimal', 'customer.name', 'exported', 'user.meta.mypref']);
yield [$template, [
'date' => 'date',
'duration' => 'duration',
'username' => 'username',
'customer' => 'customer',
'exported' => 'exported',
'user.meta.mypref' => 'mypref',
]];
}
#[DataProvider('getTestData')]
public function testWriteSpreadsheetCsv(?Template $template, array $expectedColumns): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$security = $this->createMock(Security::class);
$spreadsheetPackage = new MemoryPackage();
$exportItem = $this->createMock(ExportableItem::class);
$exportItem->method('getBegin')->willReturn(new \DateTime());
$exportItem->method('getEnd')->willReturn(new \DateTime());
$exportItem->method('getDuration')->willReturn(3600);
$exportItem->method('getRate')->willReturn(100.0);
$exportItem->method('getInternalRate')->willReturn(80.0);
$exportItem->method('getHourlyRate')->willReturn(50.0);
$exportItem->method('getFixedRate')->willReturn(200.0);
$exportItem->method('getUser')->willReturn(null);
$exportItem->method('getProject')->willReturn(null);
$exportItem->method('getActivity')->willReturn(null);
$exportItem->method('getDescription')->willReturn('Test description');
$exportItem->method('isBillable')->willReturn(true);
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
$exportItem->method('getType')->willReturn('type');
$exportItem->method('getCategory')->willReturn('category');
$renderer = new SpreadsheetRenderer($dispatcher, $security, $this->createMock(LoggerInterface::class));
$renderer->setTemplate($template);
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
$columnNames = [];
foreach ($spreadsheetPackage->getColumns() as $column) {
$columnNames[$column->getName()] = $column->getHeader();
}
self::assertEquals(null, $spreadsheetPackage->getFilename());
self::assertEquals($expectedColumns, $columnNames);
self::assertCount(2, $spreadsheetPackage->getRows());
}
}

View File

@@ -10,9 +10,11 @@
namespace App\Tests\Export\Base;
use App\Entity\User;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\AbstractSpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ColumnConverter;
use App\Export\Package\SpoutSpreadsheet;
use App\Export\Renderer\XlsxRendererFactory;
use App\Tests\Export\Renderer\AbstractRendererTestCase;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -22,12 +24,9 @@ use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Export\Base\RendererTrait
*/
#[CoversClass(XlsxRenderer::class)]
#[CoversClass(SpreadsheetRenderer::class)]
#[CoversClass(AbstractSpreadsheetRenderer::class)]
#[CoversClass(SpoutSpreadsheet::class)]
#[CoversClass(XlsxRenderer::class)]
#[Group('integration')]
class XlsxRendererTest extends AbstractRendererTestCase
{
@@ -43,30 +42,30 @@ class XlsxRendererTest extends AbstractRendererTestCase
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
return new XlsxRenderer(new SpreadsheetRenderer($dispatcher, $security), $translator);
$converter = new ColumnConverter($dispatcher, $security);
$factory = new XlsxRendererFactory($converter, $dispatcher, $translator);
return $factory->createDefault();
}
public function testConfiguration(): void
public function testConfigurationFromTemplate(): void
{
$sut = $this->getAbstractRenderer();
$sut->setLocale('de');
self::assertEquals('xlsx', $sut->getType());
self::assertEquals('xlsx', $sut->getId());
self::assertEquals('default', $sut->getTitle());
$sut->setTitle('foo-bar');
self::assertEquals('foo-bar', $sut->getTitle());
$sut->setId('bar-id');
self::assertEquals('bar-id', $sut->getId());
self::assertFalse($sut->isInternal());
$sut->setInternal(true);
self::assertTrue($sut->isInternal());
}
public function testRender(): void
{
$sut = $this->getAbstractRenderer();
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
self::assertInstanceOf(BinaryFileResponse::class, $response);
$file = $response->getFile();
$prefix = date('Ymd');

View File

@@ -0,0 +1,291 @@
<?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\Tests\Export;
use App\Entity\ActivityMeta;
use App\Entity\CustomerMeta;
use App\Entity\ProjectMeta;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\AbstractMetaDisplayEvent;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\ColumnConverter;
use App\Export\DefaultTemplate;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventDispatcher;
#[CoversClass(ColumnConverter::class)]
class ColumnConverterTest extends TestCase
{
public function testDefaultValues(): void
{
$dispatcher = new EventDispatcher();
$security = $this->createMock(Security::class);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::never())->method('warning');
$template = new DefaultTemplate($dispatcher, 'foo');
$query = new TimesheetQuery();
$sut = new ColumnConverter($dispatcher, $security, $logger);
$columns = $sut->getColumns($template, $query);
$expected = [
'date',
'begin',
'end',
'duration',
'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',
];
self::assertEquals($expected, array_keys($columns));
}
public function testSkipsRate(): void
{
$user = new User();
$dispatcher = $this->createMock(EventDispatcher::class);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user);
$security->method('isGranted')->willReturn(false);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::never())->method('warning');
$template = new DefaultTemplate($dispatcher, 'foo');
$query = new TimesheetQuery();
$query->setUser($user);
$sut = new ColumnConverter($dispatcher, $security, $logger);
$columns = $sut->getColumns($template, $query);
$expected = [
'date',
'begin',
'end',
'duration',
'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',
];
self::assertEquals($expected, array_keys($columns));
}
public function testWithMetaFields(): void
{
$user = new User();
$dispatcher = $this->createMock(EventDispatcher::class);
$dispatcher->method('dispatch')->willReturnCallback(function (AbstractMetaDisplayEvent|UserPreferenceDisplayEvent $event) {
if ($event instanceof CustomerMetaDisplayEvent) {
$event->addField((new CustomerMeta())->setName('foo_meta')->setIsVisible(true));
$event->addField((new CustomerMeta())->setName('bar_meta')->setIsVisible(true));
} elseif ($event instanceof ProjectMetaDisplayEvent) {
$event->addField((new ProjectMeta())->setName('visible_project')->setIsVisible(true));
$event->addField((new ProjectMeta())->setName('hidden_project')->setIsVisible(false));
} elseif ($event instanceof ActivityMetaDisplayEvent) {
$event->addField((new ActivityMeta())->setName('activity_world')->setIsVisible(true));
$event->addField((new ActivityMeta())->setName('hello_activity')->setIsVisible(false));
} elseif ($event instanceof TimesheetMetaDisplayEvent) {
$event->addField((new TimesheetMeta())->setName('timesheet_one')->setIsVisible(true));
$event->addField((new TimesheetMeta())->setName('timesheet_two')->setIsVisible(false));
} elseif ($event instanceof UserPreferenceDisplayEvent) {
$event->addPreference((new UserPreference('user_acme'))->setEnabled(true));
$event->addPreference((new UserPreference('user_foo'))->setEnabled(false));
}
return $event;
});
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user);
$security->method('isGranted')->willReturn(false);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::never())->method('warning');
$template = new DefaultTemplate($dispatcher, 'foo');
$query = new TimesheetQuery();
$query->setUser($user);
$sut = new ColumnConverter($dispatcher, $security, $logger);
$columns = $sut->getColumns($template, $query);
$expected = [
'date',
'begin',
'end',
'duration',
'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',
'timesheet.meta.timesheet_one',
'timesheet.meta.timesheet_two',
'customer.meta.foo_meta',
'customer.meta.bar_meta',
'project.meta.visible_project',
'project.meta.hidden_project',
'activity.meta.activity_world',
'activity.meta.hello_activity',
'user.meta.user_acme',
'user.meta.user_foo',
];
self::assertEquals($expected, array_keys($columns));
}
public function testWithAllAndUnknownColumns(): void
{
$user = new User();
$dispatcher = $this->createMock(EventDispatcher::class);
$security = $this->createMock(Security::class);
$security->method('getUser')->willReturn($user);
$security->method('isGranted')->willReturn(true);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::exactly(3))->method('warning');
$template = new Template('bar', 'foo');
$template->setColumns([
'date',
'begin',
'end',
'duration',
'duration_decimal',
'duration_seconds',
'break',
'break_decimal',
'break_seconds',
'exported',
'currency',
'rate',
'internal_rate',
'hourly_rate',
'fixed_rate',
'user.alias',
'unknown.1',
'activity.number',
'user.name',
'user.email',
'user.account_number',
'customer.name',
'project.name',
'activity.name',
'description',
'billable',
'tags',
'type',
'category',
'customer.number',
'project.tralalala',
'project.number',
'customer.vat_id',
'customer.is.never,evil',
'project.order_number',
]);
$query = new TimesheetQuery();
$query->setUser($user);
$sut = new ColumnConverter($dispatcher, $security, $logger);
$columns = $sut->getColumns($template, $query);
$expected = [
'date',
'begin',
'end',
'duration',
'duration_decimal',
'duration_seconds',
'break',
'break_decimal',
'break_seconds',
'exported',
'currency',
'rate',
'internal_rate',
'hourly_rate',
'fixed_rate',
'user.alias',
'activity.number',
'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',
];
self::assertEquals($expected, array_keys($columns));
}
}

View File

@@ -0,0 +1,114 @@
<?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\Tests\Export;
use App\Entity\User;
use App\Export\DefaultTemplate;
use App\Repository\Query\TimesheetQuery;
use App\Tests\Mocks\MetaFieldColumnSubscriberMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventDispatcher;
#[CoversClass(DefaultTemplate::class)]
class DefaultTemplateTest extends TestCase
{
public function testDefaultValues(): void
{
$template = new DefaultTemplate($this->createMock(EventDispatcherInterface::class), 'foo');
self::assertEquals('foo', $template->getId());
self::assertEquals('default', $template->getTitle());
self::assertEquals('en', $template->getLocale());
self::assertEquals([], $template->getOptions());
$columns = [
'date',
'begin',
'end',
'duration',
'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',
];
self::assertEquals($columns, $template->getColumns(new TimesheetQuery()));
}
public function testFullConstructorWithDecimalDurationAndMetaColumns(): void
{
$dispatcher = new EventDispatcher();
$dispatcher->addSubscriber(new MetaFieldColumnSubscriberMock());
$template = new DefaultTemplate($dispatcher, 'hello', null, 'world');
self::assertEquals('hello', $template->getId());
self::assertEquals('world', $template->getTitle());
self::assertNull($template->getLocale());
self::assertEquals([], $template->getOptions());
$user = new User();
$user->setPreferenceValue('export_decimal', true);
$query = new TimesheetQuery();
$query->setCurrentUser($user);
$columns = [
'date',
'begin',
'end',
'duration_decimal',
'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',
'timesheet.meta.foo',
'timesheet.meta.foo2',
'customer.meta.customer-foo',
'project.meta.project-foo',
'project.meta.project-foo2',
'activity.meta.activity-foo',
'user.meta.mypref',
];
self::assertEquals($columns, $template->getColumns($query));
}
}

View File

@@ -10,20 +10,38 @@
namespace App\Tests\Export\Package;
use App\Entity\ExportableItem;
use App\Entity\Timesheet;
use App\Export\Package\CellFormatter\CellFormatterInterface;
use App\Export\Package\CellFormatter\DateFormatter;
use App\Export\Package\Column;
use App\Export\Package\ColumnWidth;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
#[CoversClass(Column::class)]
class ColumnTest extends TestCase
{
public function testGetNameReturnsColumnName(): void
public function testDefaults(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
self::assertEquals('testName', $column->getName());
self::assertNull($column->getFormat());
self::assertEquals('testName', $column->getHeader());
self::assertSame(ColumnWidth::DEFAULT, $column->getColumnWidth());
$column->withColumnWidth(ColumnWidth::LARGE);
self::assertSame(ColumnWidth::LARGE, $column->getColumnWidth());
}
public function testThrowsOnMissiungExtractor(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing extractor on column: testName');
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
self::assertEquals('testName', $column->getValue(new Timesheet()));
}
public function withHeaderSetsHeader(): void
@@ -86,13 +104,6 @@ class ColumnTest extends TestCase
self::assertEquals('testName', $column->getHeader());
}
public function testWithFormatReturnsNull(): void
{
$formatter = $this->createMock(CellFormatterInterface::class);
$column = new Column('testName', $formatter);
self::assertNull($column->getFormat());
}
public function testWithFormatReturnsValueFromFormatter(): void
{
$column = new Column('testName', new DateFormatter());

View File

@@ -12,9 +12,6 @@ namespace App\Tests\Export\Package;
use App\Export\Package\Column;
use App\Export\Package\SpreadsheetPackage;
/**
* @covers \App\Export\Package\SpoutSpreadsheet
*/
class MemoryPackage implements SpreadsheetPackage
{
private ?string $filename = null;

View File

@@ -26,7 +26,7 @@ use Symfony\Component\HttpFoundation\Response;
abstract class AbstractRendererTestCase extends KernelTestCase
{
protected function render(ExportRendererInterface $renderer): Response
protected function render(ExportRendererInterface $renderer, bool $exportDecimal = false): Response
{
$customer = new Customer('Customer Name');
$customer->setNumber('A-0123456789');
@@ -129,11 +129,15 @@ abstract class AbstractRendererTestCase extends KernelTestCase
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6];
$currentUser = $this->createMock(User::class);
$currentUser->expects($this->any())->method('isExportDecimal')->willReturn($exportDecimal);
$query = new TimesheetQuery();
$query->setActivities([$activity]);
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
$query->setProjects([$project]);
$query->setCurrentUser($currentUser);
return $renderer->render($entries, $query);
}

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Export\Renderer;
use App\Export\Base\CsvRenderer;
use App\Export\ColumnConverter;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -24,12 +25,17 @@ class CsvRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$converter = new ColumnConverter(
$dispatcher,
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$sut = new CsvRendererFactory(
$converter,
$dispatcher,
$this->createMock(TranslatorInterface::class),
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
@@ -39,5 +45,6 @@ class CsvRendererFactoryTest extends TestCase
self::assertInstanceOf(CsvRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
self::assertTrue($renderer->isInternal());
}
}

View File

@@ -35,5 +35,6 @@ class HtmlRendererFactoryTest extends TestCase
self::assertInstanceOf(HtmlRenderer::class, $renderer);
self::assertEquals('foo', $renderer->getId());
self::assertEquals('print', $renderer->getTitle());
self::assertFalse($renderer->isInternal());
}
}

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Export\Renderer;
use App\Activity\ActivityStatisticService;
use App\Entity\User;
use App\Export\Base\HtmlRenderer;
use App\Export\Base\RendererTrait;
use App\Project\ProjectStatisticService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
@@ -23,9 +24,7 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Twig\Environment;
/**
* @covers \App\Export\Base\RendererTrait
*/
#[CoversClass(RendererTrait::class)]
#[CoversClass(HtmlRenderer::class)]
#[Group('integration')]
class HtmlRendererTest extends AbstractRendererTestCase

View File

@@ -10,27 +10,38 @@
namespace App\Tests\Export\Renderer;
use App\Export\Base\PDFRenderer;
use App\Export\Base\PdfTemplateRenderer;
use App\Export\Renderer\PdfRendererFactory;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;
use App\Export\Template;
use App\Tests\Mocks\Export\PdfRendererFactoryMock;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
#[CoversClass(PdfRendererFactory::class)]
class PdfRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new PdfRendererFactory(
$this->createMock(Environment::class),
$this->createMock(HtmlToPdfConverter::class),
$this->createMock(ProjectStatisticService::class)
);
$mock = new PdfRendererFactoryMock($this);
$sut = $mock->create();
$renderer = $sut->create('foo', 'bar.pdf.twig');
self::assertInstanceOf(PDFRenderer::class, $renderer);
self::assertEquals('foo', $renderer->getId());
self::assertFalse($renderer->isInternal());
}
public function testCreateFromTemplate(): void
{
$mock = new PdfRendererFactoryMock($this);
$sut = $mock->create();
$template = new Template('foo', 'bar');
$renderer = $sut->createFromTemplate($template);
self::assertInstanceOf(PdfTemplateRenderer::class, $renderer);
self::assertEquals('foo', $renderer->getId());
self::assertTrue($renderer->isInternal());
}
}

View File

@@ -10,8 +10,10 @@
namespace App\Tests\Export\Renderer;
use App\Export\Base\PDFRenderer;
use App\Export\Base\RendererTrait;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\MPdfConverter;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Tests\Mocks\FileHelperFactory;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -20,9 +22,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
/**
* @covers \App\Export\Base\RendererTrait
*/
#[CoversClass(PDFRendererTrait::class)]
#[CoversClass(RendererTrait::class)]
#[CoversClass(PDFRenderer::class)]
#[Group('integration')]
class PdfRendererTest extends AbstractRendererTestCase

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Export\Renderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ColumnConverter;
use App\Export\Renderer\XlsxRendererFactory;
use App\Export\Template;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -24,12 +25,17 @@ class XlsxRendererFactoryTest extends TestCase
{
public function testCreate(): void
{
$sut = new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$converter = new ColumnConverter(
$dispatcher,
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class)
);
$sut = new XlsxRendererFactory(
$converter,
$dispatcher,
$this->createMock(TranslatorInterface::class),
);
$template = new Template('foo-id', 'bar-title');
$template->setLocale('it_IT');
@@ -39,5 +45,6 @@ class XlsxRendererFactoryTest extends TestCase
self::assertInstanceOf(XlsxRenderer::class, $renderer);
self::assertEquals('foo-id', $renderer->getId());
self::assertEquals('bar-title', $renderer->getTitle());
self::assertTrue($renderer->isInternal());
}
}

View File

@@ -16,7 +16,6 @@ use App\Export\Base\HtmlRenderer;
use App\Export\Base\XlsxRenderer;
use App\Export\ExportRepositoryInterface;
use App\Export\ServiceExport;
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
use App\Project\ProjectStatisticService;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
@@ -83,10 +82,10 @@ class ServiceExportTest extends TestCase
{
$sut = $this->createSut();
self::assertEmpty($sut->getRenderer());
self::assertCount(4, $sut->getRenderer());
self::assertNull($sut->getRendererById('default'));
self::assertEmpty($sut->getTimesheetExporter());
self::assertCount(4, $sut->getTimesheetExporter());
self::assertNull($sut->getTimesheetExporterById('default'));
}
@@ -102,7 +101,7 @@ class ServiceExportTest extends TestCase
);
$sut->addRenderer($renderer);
self::assertEquals(1, \count($sut->getRenderer()));
self::assertEquals(5, \count($sut->getRenderer()));
self::assertSame($renderer, $sut->getRendererById('html'));
}
@@ -110,10 +109,18 @@ class ServiceExportTest extends TestCase
{
$sut = $this->createSut();
$exporter = new HtmlExporter($this->createMock(Environment::class), new EventDispatcher(), $this->createMock(ProjectStatisticService::class), $this->createMock(ActivityStatisticService::class));
self::assertEquals(4, \count($sut->getTimesheetExporter()));
$exporter = new HtmlRenderer(
$this->createMock(Environment::class),
new EventDispatcher(),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class),
'print'
);
$sut->addTimesheetExporter($exporter);
self::assertEquals(1, \count($sut->getTimesheetExporter()));
self::assertEquals(5, \count($sut->getTimesheetExporter()));
self::assertSame($exporter, $sut->getTimesheetExporterById('print'));
}
@@ -133,15 +140,12 @@ class ServiceExportTest extends TestCase
public function testWithTemplates(): void
{
$sut = $this->createSut(true, 5);
$sut = $this->createSut(true, 2);
$renderer = $sut->getRenderer();
self::assertCount(2, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[0]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[1]);
self::assertCount(6, $renderer);
self::assertInstanceOf(CsvRenderer::class, $renderer[4]);
self::assertInstanceOf(XlsxRenderer::class, $renderer[5]);
self::assertInstanceOf(CsvRenderer::class, $sut->getRendererById('1'));
self::assertNull($sut->getRendererById('default'));
self::assertNull($sut->getRendererById('csv'));
self::assertNull($sut->getRendererById('xlsx'));
}
}

View File

@@ -22,9 +22,13 @@ class ColumnDefinitionTest extends TestCase
return 'hello world';
});
self::assertEquals('messages', $sut->getTranslationDomain());
self::assertEquals('foo', $sut->getLabel());
self::assertEquals('bar', $sut->getType());
self::assertIsCallable($sut->getAccessor());
self::assertEquals('hello world', \call_user_func($sut->getAccessor()));
$sut->setTranslationDomain('foo');
self::assertEquals('foo', $sut->getTranslationDomain());
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Export;
use App\Export\Template;
use App\Repository\Query\TimesheetQuery;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
@@ -22,7 +23,7 @@ class TemplateTest extends TestCase
self::assertEquals('id', $template->getId());
self::assertEquals('title', $template->getTitle());
self::assertNull($template->getLocale());
self::assertEquals([], $template->getColumns());
self::assertEquals([], $template->getColumns(new TimesheetQuery()));
self::assertEquals([], $template->getOptions());
}
@@ -31,9 +32,9 @@ class TemplateTest extends TestCase
$template = new Template('id', 'title');
$columns = ['Column1', 'Column2'];
$template->setColumns($columns);
self::assertEquals($columns, $template->getColumns());
self::assertEquals($columns, $template->getColumns(new TimesheetQuery()));
$template->setColumns([]);
self::assertEquals([], $template->getColumns());
self::assertEquals([], $template->getColumns(new TimesheetQuery()));
}
public function testSetsAndGetsOptionsCorrectly(): void

View File

@@ -24,7 +24,7 @@ use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\TimesheetExportInterface;
use App\Export\ExportRendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
@@ -33,7 +33,7 @@ use Symfony\Component\HttpFoundation\Response;
abstract class AbstractRendererTestCase extends KernelTestCase
{
protected function render(TimesheetExportInterface $renderer): Response
protected function render(ExportRendererInterface $renderer): Response
{
$customer = new Customer('Customer Name');
$customer->setNumber('A-0123456789');

View File

@@ -1,63 +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\Tests\Export\Timesheet;
use App\Activity\ActivityStatisticService;
use App\Export\Timesheet\HtmlRenderer;
use App\Project\ProjectStatisticService;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
#[CoversClass(HtmlRenderer::class)]
#[Group('integration')]
class HtmlRendererTest extends AbstractRendererTestCase
{
public function testConfiguration(): void
{
$sut = new HtmlRenderer(
$this->createMock(Environment::class),
new EventDispatcher(),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class)
);
self::assertEquals('print', $sut->getId());
self::assertEquals('print', $sut->getTitle());
}
public function testRender(): void
{
$kernel = self::bootKernel();
/** @var Environment $twig */
$twig = self::getContainer()->get('twig');
/** @var RequestStack $stack */
$stack = self::getContainer()->get('request_stack');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
$sut = new HtmlRenderer(
$twig,
new EventDispatcher(),
$this->createMock(ProjectStatisticService::class),
$this->createMock(ActivityStatisticService::class)
);
$response = $this->render($sut);
$content = $response->getContent();
self::assertStringContainsString('>1:50<', $content);
}
}

View File

@@ -10,8 +10,10 @@
namespace App\Tests\Export\Timesheet;
use App\Export\Base\PDFRenderer;
use App\Export\Base\RendererTrait;
use App\Pdf\HtmlToPdfConverter;
use App\Pdf\MPdfConverter;
use App\Pdf\PdfRendererTrait;
use App\Project\ProjectStatisticService;
use App\Tests\Mocks\FileHelperFactory;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -20,9 +22,8 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Twig\Environment;
/**
* @covers \App\Export\Base\RendererTrait
*/
#[CoversClass(PDFRendererTrait::class)]
#[CoversClass(RendererTrait::class)]
#[CoversClass(PDFRenderer::class)]
#[Group('integration')]
class PdfRendererTest extends AbstractRendererTestCase

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Mocks\Export;
use App\Export\ColumnConverter;
use App\Export\Renderer\CsvRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -20,11 +21,17 @@ class CsvRendererFactoryMock extends AbstractMockFactory
{
public function create(): CsvRendererFactory
{
return new CsvRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$converter = new ColumnConverter(
$dispatcher,
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
return new CsvRendererFactory(
$converter,
$dispatcher,
$this->createMock(TranslatorInterface::class),
);
}
}

View File

@@ -9,20 +9,33 @@
namespace App\Tests\Mocks\Export;
use App\Export\ColumnConverter;
use App\Export\Renderer\PdfRendererFactory;
use App\Pdf\HtmlToPdfConverter;
use App\Project\ProjectStatisticService;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Translation\LocaleSwitcher;
use Twig\Environment;
class PdfRendererFactoryMock extends AbstractMockFactory
{
public function create(): PdfRendererFactory
{
$converter = new ColumnConverter(
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Security::class),
$this->createMock(LoggerInterface::class),
);
return new PdfRendererFactory(
$this->createMock(Environment::class),
$this->createMock(HtmlToPdfConverter::class),
$this->createMock(ProjectStatisticService::class),
$this->createMock(LocaleSwitcher::class),
$converter
);
}
}

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Mocks\Export;
use App\Export\ColumnConverter;
use App\Export\Renderer\XlsxRendererFactory;
use App\Tests\Mocks\AbstractMockFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -20,11 +21,17 @@ class XlsxRendererFactoryMock extends AbstractMockFactory
{
public function create(): XlsxRendererFactory
{
return new XlsxRendererFactory(
$this->createMock(EventDispatcherInterface::class),
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$converter = new ColumnConverter(
$dispatcher,
$this->createMock(Security::class),
$this->createMock(TranslatorInterface::class),
$this->createMock(LoggerInterface::class),
);
return new XlsxRendererFactory(
$converter,
$dispatcher,
$this->createMock(TranslatorInterface::class),
);
}
}

View File

@@ -1351,11 +1351,6 @@ parameters:
count: 1
path: Export/Spreadsheet/Writer/BinaryFileResponseWriterTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 1
path: Export/Timesheet/HtmlRendererTest.php
-
message: "#^Parameter \\#1 \\$items of method App\\\\Export\\\\TimesheetExportRepository\\:\\:setExported\\(\\) expects array\\<App\\\\Entity\\\\Timesheet\\>, array\\<int, App\\\\Entity\\\\Activity\\|App\\\\Entity\\\\Timesheet\\|stdClass\\|null\\> given\\.$#"
count: 1

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ar" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>إفتراضي</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target>بالميزانية المتبقية</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target>بالمعدلات الداخلية</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>ورقة التوقيت</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="bg" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig" xml:space="preserve">
<source>Default</source>
<target state="translated">По подразбиране</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" xml:space="preserve">
<source>With remaining budget</source>
<target state="translated">С оставащия бюджет</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" xml:space="preserve">
<source>With internal rates</source>
<target state="translated">С вътрешни тарифи</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Работно време</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="cs" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">Výchozí</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Se zbývajícím rozpočtem</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>S interními sazbami</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Pracovní výkaz</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="da" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig" xml:space="preserve">
<source>Default</source>
<target state="translated">Standard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" xml:space="preserve">
<source>With remaining budget</source>
<target state="translated">Med tilbageværende budget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" xml:space="preserve">
<source>With internal rates</source>
<target state="translated">Med interne priser</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Timeoverblik</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Inkl. Restbudget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Inkl. interner Kosten</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Stundennachweis</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="de-CH" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">Standard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target state="translated">Mit Restbudget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target state="translated">Mit internen Tarifen</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Stundenzettel</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="el" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Τυπικό</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Με υπολειπόμενο προϋπολογισμό</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Με εσωτερικές χρεώσεις</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Χρονοδιάγραμμα</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="en" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Default</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target>With remaining budget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target>With internal rates</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Timesheet</target>

View File

@@ -2,18 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="eo" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Kutima</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Kun restanta buĝeto</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Kun internaj kotizoj</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="es" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Estándar</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Con presupuesto remanente</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Con tasas internas</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Parte de horas</target>

View File

@@ -2,18 +2,7 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="et" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig" xml:space="preserve">
<source>Default</source>
<target state="translated">Vaikevalik</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" xml:space="preserve">
<source>With remaining budget</source>
<target state="translated">Allesjäänud eelarvega</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" xml:space="preserve">
<source>With internal rates</source>
<target state="translated">Sisemiste hindadega</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fa" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">پیش فرض</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target state="translated">با بودجه باقی مانده</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target state="translated">با نرخ داخلی</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">تایم شیت</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fi" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target state="translated">Standardi</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target state="translated">Jäljellä olevalla budjetilla</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target state="translated">Sisäisillä hinnoilla</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Kellokortti</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Par défaut</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Avec le budget restant</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Avec les taux internes</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Feuille de temps</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="he" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>ברירת מחדל</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>עם תקציב נותר</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>עם תעריפים פנימיים</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>גיליון שעות</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hr" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Standardno</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>S preostalim budžetom</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target state="translated">S internim cijenama</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Vremenska tablica</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="hu" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Általános</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Maradék költségvetéssel</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Belső bérekkel</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Jelenléti ív</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="id" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">Bawaan</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" xml:space="preserve">
<source>With remaining budget</source>
<target state="translated">Dengan anggaran yang tersisa</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" xml:space="preserve">
<source>With internal rates</source>
<target state="translated">Dengan tarif internal</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve">
<source>Timesheet</source>
<target state="translated">Lembar Waktu</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="it" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig" approved="yes">
<source>Default</source>
<target state="final">Predefinito</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" approved="yes">
<source>With remaining budget</source>
<target state="final">Con il budget rimanente</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" approved="yes">
<source>With internal rates</source>
<target state="final">Con i costi interni</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" approved="yes">
<source>Timesheet</source>
<target state="final">Foglio di presenza</target>

View File

@@ -2,10 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ja" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">デフォルト</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">タイムシート</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ko" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>기본값</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target>남은 예산 포함</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target>내부 수익률 포함</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>근무 시간 기록표</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="nb-NO" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Forvalg</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>With remaining budget</source>
<target>Med restbudsjett</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>With internal rates</source>
<target>Med intern takst</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Timeseddel</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="nl" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standaard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Met resterend budget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Met interne tarieven</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Tijdrooster</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="pl" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">Domyślne</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Z pozostałym budżetem</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Przy stawkach wewnętrznych</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Ewidencja pomiarów</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="pt" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target state="translated">Predefinido</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Com orçamento restante</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Com taxas internas</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Quadro de horários</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="pt-BR" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Padrão</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Com o orçamento restante</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Com as taxas internas</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Planilha de horários</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ro" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>Default</source>
<target>Implicit</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Cu bugetul rămas</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Cu tarifele interne</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target>Fișă de pontaj</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ru" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Стандартный</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>С оставшимся бюджетом</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>С внутренними ставками</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Табель рабочего времени</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="sk" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Štandard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Vrátane zostávajúceho rozpočtu</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Vrátane interných nákladov</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Pracovný výkaz</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="sv" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
<source>default.pdf.twig</source>
<target>Standard</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig">
<source>default-budget.pdf.twig</source>
<target>Med återstående budget</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig">
<source>default-internal.pdf.twig</source>
<target>Med interna kostnader</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig">
<source>Timesheet</source>
<target state="translated">Tidrapport</target>

View File

@@ -2,18 +2,6 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="ta" datatype="plaintext" original="export.en.xlf">
<body>
<trans-unit id="oRRnhwf" resname="default.pdf.twig" xml:space="preserve" approved="yes">
<source>Default</source>
<target state="final">இயல்புநிலை</target>
</trans-unit>
<trans-unit id="YjWg9hr" resname="default-budget.pdf.twig" xml:space="preserve" approved="yes">
<source>With remaining budget</source>
<target state="final">மீதமுள்ள நிதித்திட்டத்துடன்</target>
</trans-unit>
<trans-unit id="AuhL8Sb" resname="default-internal.pdf.twig" xml:space="preserve" approved="yes">
<source>With internal rates</source>
<target state="final">உள் விகிதங்களுடன்</target>
</trans-unit>
<trans-unit id="dzZWfU1" resname="timesheet.pdf.twig" xml:space="preserve" approved="yes">
<source>Timesheet</source>
<target state="final">நேரத்தாள்</target>

Some files were not shown because too many files have changed in this diff Show More