configurable csv/xlsx export templates (#5531)

This commit is contained in:
Kevin Papst
2025-06-11 13:06:33 +02:00
committed by GitHub
parent 46129c7ab9
commit 05aaa1950a
83 changed files with 2632 additions and 412 deletions

View File

@@ -26,6 +26,10 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
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
@@ -33,14 +37,29 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
{
}
public function setId(string $id): void
{
$this->id = $id;
}
public function getId(): string
{
return 'csv';
return $this->id;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}
public function getTitle(): string
{
return 'csv';
return $this->title;
}
/**
@@ -58,7 +77,7 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
/**
* @param ExportableItem[] $exportItems
*/
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
@@ -68,11 +87,12 @@ final class CsvRenderer implements RendererInterface, TimesheetExportInterface
$options = new Options();
$options->SHOULD_ADD_BOM = false;
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator);
$spreadsheet = new SpoutSpreadsheet(new Writer($options), $this->translator, $this->locale);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->registerFormatter('date', new DateStringFormatter());
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter());
$this->spreadsheetRenderer->registerFormatter('duration', new DurationPlainFormatter(false));
$this->spreadsheetRenderer->registerFormatter('duration_seconds', new DurationPlainFormatter(true));
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
return new \SplFileInfo($filename);

View File

@@ -68,9 +68,9 @@ class HtmlRenderer implements ExportRendererInterface
}
/**
* @param ExportableItem[] $timesheets
* @param ExportableItem[] $exportItems
*/
public function render(array $timesheets, TimesheetQuery $query): Response
public function render(array $exportItems, TimesheetQuery $query): Response
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
@@ -81,7 +81,7 @@ class HtmlRenderer implements ExportRendererInterface
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$summary = $this->calculateSummary($timesheets);
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
@@ -89,11 +89,11 @@ class HtmlRenderer implements ExportRendererInterface
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $timesheets,
'entries' => $exportItems,
'query' => $query,
'summaries' => $summary,
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
'activity_budgets' => $this->calculateActivityBudget($timesheets, $query, $this->activityStatisticService),
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
'activity_budgets' => $this->calculateActivityBudget($exportItems, $query, $this->activityStatisticService),
'timesheetMetaFields' => $timesheetMetaFields,
'customerMetaFields' => $customerMetaFields,
'projectMetaFields' => $projectMetaFields,

View File

@@ -29,6 +29,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
use PDFRendererTrait;
private string $id = 'pdf';
private string $title = 'pdf';
private string $template = 'default.pdf.twig';
private array $pdfOptions = [];
@@ -42,7 +43,7 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
public function getTitle(): string
{
return 'pdf';
return $this->title;
}
protected function getTemplate(): string
@@ -75,20 +76,18 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
}
/**
* @param ExportableItem[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @param ExportableItem[] $exportItems
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
public function render(array $exportItems, TimesheetQuery $query): Response
{
$filename = new ExportFilename($query);
$context = new PdfContext();
$context->setOption('filename', $filename->getFilename());
$summary = $this->calculateSummary($timesheets);
$summary = $this->calculateSummary($exportItems);
// enable basic security measures
$sandbox = new SandboxExtension(new ExportPolicy());
@@ -96,10 +95,10 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
$this->twig->addExtension($sandbox);
$content = $this->twig->render($this->getTemplate(), array_merge([
'entries' => $timesheets,
'entries' => $exportItems,
'query' => $query,
'summaries' => $summary,
'budgets' => $this->calculateProjectBudget($timesheets, $query, $this->projectStatisticService),
'budgets' => $this->calculateProjectBudget($exportItems, $query, $this->projectStatisticService),
'decimal' => false,
'pdfContext' => $context
], $this->getOptions($query)));
@@ -111,18 +110,19 @@ class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface
return $this->createPdfResponse($content, $context);
}
public function setTemplate(string $filename): PDFRenderer
public function setTemplate(string $filename): void
{
$this->template = $filename;
return $this;
}
public function setId(string $id): PDFRenderer
public function setId(string $id): void
{
$this->id = $id;
}
return $this;
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getId(): string

View File

@@ -31,11 +31,14 @@ 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;
/**
@@ -48,12 +51,32 @@ final class SpreadsheetRenderer
*/
private array $formatter = [];
private ?TemplateInterface $template = null;
public function __construct(
protected EventDispatcherInterface $dispatcher,
protected Security $voter
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Security $voter,
private readonly ?LoggerInterface $logger = null,
) {
}
public function setTemplate(?TemplateInterface $template): void
{
$this->template = $template;
}
private function getTemplate(): TemplateInterface
{
if ($this->template === null) {
$template = new Template('default', 'default');
$template->setColumns($this->getDefaultColumns());
return $template;
}
return $this->template;
}
private function isRenderRate(TimesheetQuery $query): bool
{
if ($this->voter->getUser() === null) {
@@ -73,7 +96,7 @@ final class SpreadsheetRenderer
*/
private function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
$this->eventDispatcher->dispatch($event);
return $event->getFields();
}
@@ -98,7 +121,12 @@ final class SpreadsheetRenderer
if ($currentRow > 1) {
$totalColumns = ['duration', 'rate', 'internalRate'];
$columnNames = range('A', 'Z');
// 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) {
@@ -131,8 +159,9 @@ final class SpreadsheetRenderer
return match ($name) {
'date' => new DateFormatter(),
'time' => new TimeFormatter(),
'duration' => new DurationFormatter(),
'duration' => new DurationFormatter('[hh]:mm'),
'duration_decimal' => new DurationDecimalFormatter(),
'duration_seconds' => new DurationFormatter('[hh]:mm:ss'),
default => new DefaultFormatter()
};
}
@@ -144,98 +173,214 @@ final class SpreadsheetRenderer
{
$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();
$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 === '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.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(): array
{
// @deprecated since 2.36 - will be removed with 3.0
$durationFormatter = 'duration';
if (($user = $this->voter->getUser()) instanceof User) {
$durationFormatter = $user->isExportDecimal() ? 'duration_decimal' : 'duration';
}
$columns = [];
$columns = [
'date',
'begin',
'end',
$durationFormatter,
'currency',
'rate',
'internal_rate',
'hourly_rate',
'fixed_rate',
'user.alias',
'user.name',
'user.account_number',
'customer.name',
'project.name',
'activity.name',
'description',
'billable',
'tags',
'type',
'category',
'customer.number',
'project.number',
'customer.vat_id',
'project.order_number',
];
$columns[] = (new Column('date', $this->getFormatter('date')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
$columns[] = (new Column('begin', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('end', $this->getFormatter('time')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('duration', $this->getFormatter($durationFormatter)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration())->withColumnWidth(ColumnWidth::SMALL);
if ($showRates) {
$columns[] = (new Column('currency', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'timesheet.meta.' . $metaField->getName();
}
}
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('activity', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription())->withColumnWidth(ColumnWidth::LARGE);
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable())->withColumnWidth(ColumnWidth::SMALL);
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
$columns[] = (new Column('type', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
$columns[] = (new Column('category', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
$columns[] = (new Column('number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
$columns[] = (new Column('project_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
$columns[] = (new Column('vat_id', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
$columns[] = (new Column('orderNumber', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'customer.meta.' . $metaField->getName();
}
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), $this->getFormatter('default')))
->withHeader($metaField->getLabel())
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
return $exportableItem->getMetaField($metaField->getName())?->getValue();
});
}
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'project.meta.' . $metaField->getName();
}
$columns[] = (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();
});
}
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() !== null) {
$columns[] = 'activity.meta.' . $metaField->getName();
}
$columns[] = (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();
});
}
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
if ($metaField->getName() === null) {
continue;
}
$columns[] = (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();
});
}
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $metaField) {
if ($metaField->getName() === null) {
continue;
if ($metaField->getName() !== null) {
$columns[] = 'user.meta.' . $metaField->getName();
}
$columns[] = (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();
});
}
return $columns;

View File

@@ -23,6 +23,10 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
use ExportTrait;
private string $id = 'xlsx';
private string $title = 'default';
private ?string $locale = null;
public function __construct(
private readonly SpreadsheetRenderer $spreadsheetRenderer,
private readonly TranslatorInterface $translator,
@@ -30,14 +34,29 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
{
}
public function setId(string $id): void
{
$this->id = $id;
}
public function getId(): string
{
return 'xlsx';
return $this->id;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}
public function getTitle(): string
{
return 'xlsx';
return $this->title;
}
/**
@@ -55,14 +74,14 @@ final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
/**
* @param ExportableItem[] $exportItems
*/
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
private function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
{
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator);
$spreadsheet = new SpoutSpreadsheet(new Writer(), $this->translator, $this->locale);
$spreadsheet->open($filename);
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);

View File

@@ -17,18 +17,10 @@ interface ExportRendererInterface
{
/**
* @param ExportableItem[] $exportItems
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $exportItems, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
/**
* @return string
*/
public function getTitle(): string;
}

View File

@@ -13,7 +13,7 @@ use App\Utils\Duration;
final class DurationDecimalFormatter implements CellFormatterInterface
{
private Duration $duration;
private readonly Duration $duration;
public function __construct()
{

View File

@@ -11,6 +11,10 @@ namespace App\Export\Package\CellFormatter;
final class DurationFormatter implements CellFormatterInterface, CellWithFormatInterface
{
public function __construct(private readonly string $format)
{
}
public function formatValue(mixed $value): mixed
{
if (!is_numeric($value) || (int) $value === 0) {
@@ -37,6 +41,6 @@ final class DurationFormatter implements CellFormatterInterface, CellWithFormatI
public function getFormat(): string
{
return '[hh]:mm:ss';
return $this->format;
}
}

View File

@@ -11,10 +11,18 @@ namespace App\Export\Package\CellFormatter;
final class DurationPlainFormatter implements CellFormatterInterface
{
public function __construct(private readonly bool $withSeconds = true)
{
}
public function formatValue(mixed $value): mixed
{
if (!is_numeric($value) || (int) $value === 0) {
return '0:00:00';
if ($this->withSeconds) {
return '0:00:00';
}
return '0:00';
}
$value = (int) $value;
@@ -32,6 +40,10 @@ final class DurationPlainFormatter implements CellFormatterInterface
$interval->invert = 1;
}
return $interval->format('%r%h:%I:%S');
if ($this->withSeconds) {
return $interval->format('%r%h:%I:%S');
}
return $interval->format('%r%h:%I');
}
}

View File

@@ -31,7 +31,8 @@ class SpoutSpreadsheet implements SpreadsheetPackage
public function __construct(
private readonly WriterInterface $writer,
private readonly TranslatorInterface $translator
private readonly TranslatorInterface $translator,
private readonly ?string $locale = null,
)
{
$this->writer->setCreator(Constants::SOFTWARE);
@@ -50,11 +51,11 @@ class SpoutSpreadsheet implements SpreadsheetPackage
$tmp = [];
$i = 0;
foreach ($columns as $column) {
$title = $this->translator->trans($column->getHeader());
$title = $this->translator->trans($column->getHeader(), [], null, $this->locale);
$tmp[] = Cell::fromValue($title);
$style = null;
if ($column->getFormat() !== null) {
$style = (new Style())->setFormat($column->getFormat());
if (($format = $column->getFormat()) !== null) {
$style = (new Style())->setFormat($format);
}
$this->styles[$i++] = $style;
}
@@ -120,8 +121,10 @@ class SpoutSpreadsheet implements SpreadsheetPackage
if ($this->writer instanceof AbstractWriterMultiSheets) {
$sheetView = new SheetView();
$sheetView->setFreezeColumn('D');
$sheetView->setFreezeRow(2);
// deactivated, because the column order is now configurable
//$sheetView->setFreezeColumn('D');
//$sheetView->setFreezeRow(2);
$this->writer->getCurrentSheet()->setSheetView($sheetView);
}

View File

@@ -0,0 +1,42 @@
<?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\Renderer;
use App\Export\Base\CsvRenderer;
use App\Export\Base\SpreadsheetRenderer;
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 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());
return $renderer;
}
}

View File

@@ -27,6 +27,7 @@ final class PdfRendererFactory
{
$renderer = new PDFRenderer($this->twig, $this->converter, $this->projectStatisticService);
$renderer->setId($id);
$renderer->setTitle($id);
$renderer->setTemplate($template);
return $renderer;

View File

@@ -0,0 +1,42 @@
<?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\Renderer;
use App\Export\Base\SpreadsheetRenderer;
use App\Export\Base\XlsxRenderer;
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 TranslatorInterface $translator,
private readonly ?LoggerInterface $logger = null,
) {
}
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());
return $renderer;
}
}

View File

@@ -11,10 +11,14 @@ namespace App\Export;
use App\Entity\ExportableItem;
use App\Event\ExportItemsQueryEvent;
use App\Export\Renderer\CsvRendererFactory;
use App\Export\Renderer\HtmlRendererFactory;
use App\Export\Renderer\PdfRendererFactory;
use App\Export\Renderer\XlsxRendererFactory;
use App\Repository\ExportTemplateRepository;
use App\Repository\Query\ExportQuery;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
final class ServiceExport
{
@@ -36,9 +40,13 @@ final class ServiceExport
private array $repositories = [];
public function __construct(
private EventDispatcherInterface $eventDispatcher,
private HtmlRendererFactory $htmlRendererFactory,
private PdfRendererFactory $pdfRendererFactory
private readonly EventDispatcherInterface $eventDispatcher,
private readonly HtmlRendererFactory $htmlRendererFactory,
private readonly PdfRendererFactory $pdfRendererFactory,
private readonly CsvRendererFactory $csvRendererFactory,
private readonly XlsxRendererFactory $xlsxRendererFactory,
private readonly ExportTemplateRepository $exportTemplateRepository,
private readonly LoggerInterface $logger,
)
{
}
@@ -73,6 +81,26 @@ final class ServiceExport
{
$renderer = [];
foreach ($this->exportTemplateRepository->findAll() as $template) {
$tpl = new Template((string) $template->getId(), $template->getTitle()); // @phpstan-ignore argument.type
$tpl->setColumns($template->getColumns());
$tpl->setLocale($template->getLanguage());
switch ($template->getRenderer()) {
case 'csv':
$renderer[] = $this->csvRendererFactory->create($tpl);
break;
case 'xlsx':
$renderer[] = $this->xlsxRendererFactory->create($tpl);
break;
default:
$this->logger->error('Unknown export template type: ' . $template->getRenderer());
break;
}
}
foreach ($this->documentDirs as $exportPath) {
if (!is_dir($exportPath)) {
continue;
@@ -132,7 +160,7 @@ final class ServiceExport
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
{
foreach ($this->timesheetExporter as $exporter) {
foreach ($this->getTimesheetExporter() as $exporter) {
if ($exporter->getId() === $id) {
return $exporter;
}
@@ -147,7 +175,6 @@ final class ServiceExport
}
/**
* @param ExportQuery $query
* @return ExportableItem[]
* @throws TooManyItemsExportException
*/

79
src/Export/Template.php Normal file
View File

@@ -0,0 +1,79 @@
<?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;
final class Template implements TemplateInterface
{
private ?string $locale = null;
/**
* @var array<int, string>
*/
private array $columns = [];
/**
* @var array<string, mixed>
*/
private array $options = [];
public function __construct(private readonly string $id, private readonly string $title)
{
}
/**
* @param array<int, string> $columns
*/
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
/**
* @return array<string, mixed>
*/
public function getOptions(): array
{
return $this->options;
}
/**
* @param array<string, mixed> $options
*/
public function setOptions(array $options): void
{
$this->options = $options;
}
public function getLocale(): ?string
{
return $this->locale;
}
public function setLocale(?string $locale): void
{
$this->locale = $locale;
}
public function getId(): string
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
/**
* @return array<int, string>
*/
public function getColumns(): array
{
return $this->columns;
}
}

View File

@@ -0,0 +1,29 @@
<?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;
interface TemplateInterface
{
public function getId(): string;
public function getTitle(): string;
/**
* @return array<int, string>
*/
public function getColumns(): array;
public function getLocale(): ?string;
/**
* @return array<string, mixed>
*/
public function getOptions(): array;
}