configurable csv/xlsx export templates (#5531)
This commit is contained in:
47
src/API/ExportController.php
Normal file
47
src/API/ExportController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\API;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use FOS\RestBundle\View\View;
|
||||
use FOS\RestBundle\View\ViewHandlerInterface;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route(path: '/export')]
|
||||
#[IsGranted('API')]
|
||||
#[OA\Tag(name: 'Export')]
|
||||
final class ExportController extends BaseApiController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ExportTemplateRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete export template
|
||||
*/
|
||||
#[IsGranted('create_export_template')]
|
||||
#[OA\Delete(responses: [new OA\Response(response: 204, description: 'Delete export template')], x: ['internal' => true])]
|
||||
#[OA\Parameter(name: 'id', description: 'Export template ID to delete', in: 'path', required: true)]
|
||||
#[Route(path: '/{id}', name: 'delete_export_template', requirements: ['id' => '\d+'], methods: ['DELETE'])]
|
||||
public function deleteTemplate(ExportTemplate $exportTemplate): Response
|
||||
{
|
||||
$this->repository->removeExportTemplate($exportTemplate);
|
||||
|
||||
$view = new View(null, Response::HTTP_NO_CONTENT);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,13 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Export\Base\DispositionInlineInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\TooManyItemsExportException;
|
||||
use App\Form\ExportTemplateSpreadsheetForm;
|
||||
use App\Form\Toolbar\ExportToolbarForm;
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Repository\Query\ExportQuery;
|
||||
use App\Utils\PageSetup;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -29,7 +32,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('create_export')]
|
||||
final class ExportController extends AbstractController
|
||||
{
|
||||
public function __construct(private ServiceExport $export)
|
||||
public function __construct(private readonly ServiceExport $export)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -79,6 +82,23 @@ final class ExportController extends AbstractController
|
||||
$page = new PageSetup('export');
|
||||
$page->setHelp('export.html');
|
||||
|
||||
$buttons = [];
|
||||
foreach ($this->export->getRenderer() as $renderer) {
|
||||
$class = \get_class($renderer);
|
||||
$pos = strrpos($class, '\\');
|
||||
if ($pos !== false) {
|
||||
$class = substr($class, $pos + 1);
|
||||
}
|
||||
$class = strtolower(str_replace('Renderer', '', $class));
|
||||
$buttons[$class][$renderer->getId()] = $renderer->getTitle();
|
||||
}
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
$showRates = $this->isGranted('view_rate_other_timesheet');
|
||||
} else {
|
||||
$showRates = $this->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->render('export/index.html.twig', [
|
||||
'page_setup' => $page,
|
||||
'too_many' => $tooManyResults,
|
||||
@@ -86,10 +106,10 @@ final class ExportController extends AbstractController
|
||||
'query' => $query,
|
||||
'entries' => $entries,
|
||||
'form' => $form->createView(),
|
||||
'renderer' => $this->export->getRenderer(),
|
||||
'buttons' => $buttons,
|
||||
'preview_limit' => $maxItemsPreview,
|
||||
'preview_show' => $showPreview,
|
||||
'decimal' => $this->getUser()->isExportDecimal(),
|
||||
'show_rates' => $showRates,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -141,7 +161,6 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @return ExportableItem[]
|
||||
* @throws TooManyItemsExportException
|
||||
*/
|
||||
@@ -158,8 +177,6 @@ final class ExportController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportQuery $query
|
||||
* @param string $method
|
||||
* @return FormInterface<ExportQuery>
|
||||
*/
|
||||
private function getToolbarForm(ExportQuery $query, string $method): FormInterface
|
||||
@@ -175,4 +192,40 @@ final class ExportController extends AbstractController
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/template-create', name: 'export_template_create', methods: ['GET', 'POST'])]
|
||||
public function createExportTemplate(Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_create'), $request, $repository, new ExportTemplate());
|
||||
}
|
||||
|
||||
#[Route(path: '/template-edit/{exportTemplate}', name: 'export_template_edit', methods: ['GET', 'POST'])]
|
||||
public function editExportTemplate(ExportTemplate $exportTemplate, Request $request, ExportTemplateRepository $repository): Response
|
||||
{
|
||||
return $this->editExportForm($this->generateUrl('export_template_edit', ['exportTemplate' => $exportTemplate->getId()]), $request, $repository, $exportTemplate);
|
||||
}
|
||||
|
||||
private function editExportForm(string $url, Request $request, ExportTemplateRepository $repository, ExportTemplate $exportTemplate): Response
|
||||
{
|
||||
$form = $this->createForm(ExportTemplateSpreadsheetForm::class, $exportTemplate, [
|
||||
'action' => $url,
|
||||
'method' => 'POST',
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$repository->saveExportTemplate($exportTemplate);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('export');
|
||||
} catch (\Exception $ex) {
|
||||
$this->handleFormUpdateException($ex, $form);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('export/template.html.twig', [
|
||||
'form' => $form->createView()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
136
src/Entity/ExportTemplate.php
Normal file
136
src/Entity/ExportTemplate.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Repository\ExportTemplateRepository;
|
||||
use App\Validator\Constraints\ExportRenderer;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Table(name: 'kimai2_export_templates')]
|
||||
#[ORM\UniqueConstraint(columns: ['title'])]
|
||||
#[ORM\Entity(repositoryClass: ExportTemplateRepository::class)]
|
||||
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
|
||||
#[UniqueEntity('title')]
|
||||
class ExportTemplate
|
||||
{
|
||||
#[ORM\Column(name: 'id', type: Types::INTEGER)]
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
|
||||
private ?int $id = null;
|
||||
#[ORM\Column(name: 'title', type: Types::STRING, length: 100, nullable: false)]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Length(min: 1, max: 100)]
|
||||
private ?string $title = null;
|
||||
#[ORM\Column(name: 'renderer', type: Types::STRING, length: 20, nullable: false)]
|
||||
#[ExportRenderer]
|
||||
#[Assert\NotBlank]
|
||||
private string $renderer = 'csv';
|
||||
/**
|
||||
* Used for header column translation.
|
||||
*/
|
||||
#[ORM\Column(name: 'language', type: Types::STRING, length: 6, nullable: true)]
|
||||
#[Assert\Locale]
|
||||
private ?string $language = null;
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
#[ORM\Column(name: 'columns', type: Types::JSON, nullable: false)]
|
||||
#[Assert\Count(min: 1)]
|
||||
#[Assert\NotNull]
|
||||
private array $columns = [];
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
#[ORM\Column(name: 'options', type: Types::JSON, nullable: false)]
|
||||
#[Assert\NotNull]
|
||||
private array $options = [];
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setRenderer(string $renderer): void
|
||||
{
|
||||
$this->renderer = $renderer;
|
||||
}
|
||||
|
||||
public function getRenderer(): string
|
||||
{
|
||||
return $this->renderer;
|
||||
}
|
||||
|
||||
public function getLanguage(): ?string
|
||||
{
|
||||
return $this->language;
|
||||
}
|
||||
|
||||
public function setLanguage(?string $language): void
|
||||
{
|
||||
$this->language = $language;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getColumns(): array
|
||||
{
|
||||
return $this->columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|null $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 __toString(): string
|
||||
{
|
||||
return $this->title ?? 'New';
|
||||
}
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
$this->id = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* @var Collection<UserPreference>|null
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
|
||||
private ?Collection $preferences;
|
||||
private ?Collection $preferences = null;
|
||||
/**
|
||||
* List of all team memberships.
|
||||
*
|
||||
@@ -504,10 +504,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
return $allowNull ? $value : ($value ?? $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param UserPreference $preference
|
||||
* @return User
|
||||
*/
|
||||
public function addPreference(UserPreference $preference): User
|
||||
{
|
||||
if (null === $this->preferences) {
|
||||
@@ -586,9 +582,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is member of any team.
|
||||
*
|
||||
* @return bool
|
||||
* Checks if the user is a member of any team.
|
||||
*/
|
||||
public function hasTeamAssignment(): bool
|
||||
{
|
||||
@@ -599,7 +593,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
* Checks is the user is teamlead in any of the assigned teams.
|
||||
*
|
||||
* @see User::hasTeamleadRole()
|
||||
* @return bool
|
||||
*/
|
||||
public function isTeamlead(): bool
|
||||
{
|
||||
@@ -614,9 +607,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Checks if the given user is a team member.
|
||||
*
|
||||
* @param User $user
|
||||
* @return bool
|
||||
*/
|
||||
public function hasTeamMember(User $user): bool
|
||||
{
|
||||
@@ -680,8 +670,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function addTeam(Team $team): void
|
||||
{
|
||||
@@ -700,8 +688,6 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
|
||||
/**
|
||||
* Required in the User profile screen to edit his teams.
|
||||
*
|
||||
* @param Team $team
|
||||
*/
|
||||
public function removeTeam(Team $team): void
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use App\Utils\Duration;
|
||||
|
||||
final class DurationDecimalFormatter implements CellFormatterInterface
|
||||
{
|
||||
private Duration $duration;
|
||||
private readonly Duration $duration;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
42
src/Export/Renderer/CsvRendererFactory.php
Normal file
42
src/Export/Renderer/CsvRendererFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
42
src/Export/Renderer/XlsxRendererFactory.php
Normal file
42
src/Export/Renderer/XlsxRendererFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
79
src/Export/Template.php
Normal 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;
|
||||
}
|
||||
}
|
||||
29
src/Export/TemplateInterface.php
Normal file
29
src/Export/TemplateInterface.php
Normal 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;
|
||||
}
|
||||
43
src/Form/ExportTemplateSpreadsheetForm.php
Normal file
43
src/Form/ExportTemplateSpreadsheetForm.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use App\Form\Type\ExportColumnsType;
|
||||
use App\Form\Type\ExportRendererType;
|
||||
use App\Form\Type\LanguageType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ExportTemplateSpreadsheetForm extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('title', TextType::class);
|
||||
$builder->add('renderer', ExportRendererType::class, ['label' => 'type']);
|
||||
$builder->add('language', LanguageType::class, ['required' => false]);
|
||||
$builder->add('columns', ExportColumnsType::class, ['required' => true]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => ExportTemplate::class,
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'export_template_create',
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.exportTemplate'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -40,18 +40,22 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
|
||||
$extendedOptions = ['class' => 'selectpicker'];
|
||||
|
||||
if ($options['multiple']) {
|
||||
if (\array_key_exists('multiple', $options) && $options['multiple']) {
|
||||
$extendedOptions['size'] = 1;
|
||||
}
|
||||
|
||||
if (false !== $options['width']) {
|
||||
if (\array_key_exists('width', $options) && false !== $options['width']) {
|
||||
$extendedOptions['data-width'] = $options['width'];
|
||||
}
|
||||
|
||||
if (false === $options['search']) {
|
||||
if (\array_key_exists('search', $options) && false === $options['search']) {
|
||||
$extendedOptions['data-disable-search'] = 1;
|
||||
}
|
||||
|
||||
if (\array_key_exists('order', $options) && true === $options['order']) {
|
||||
$extendedOptions['data-order'] = 1;
|
||||
}
|
||||
|
||||
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
|
||||
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
|
||||
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
|
||||
@@ -75,5 +79,9 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
$resolver->setDefined(['search']);
|
||||
$resolver->setAllowedTypes('search', 'boolean');
|
||||
$resolver->setDefault('search', true);
|
||||
|
||||
$resolver->setDefined(['order']);
|
||||
$resolver->setAllowedTypes('order', 'boolean');
|
||||
$resolver->setDefault('order', false);
|
||||
}
|
||||
}
|
||||
|
||||
166
src/Form/Type/ExportColumnsType.php
Normal file
166
src/Form/Type/ExportColumnsType.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?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 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\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\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
final class ExportColumnsType extends AbstractType
|
||||
{
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private array $ordered = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly TranslatorInterface $translator,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$columns = [
|
||||
'timesheet' => [
|
||||
'date' => 'date',
|
||||
'begin' => 'begin',
|
||||
'end' => 'end',
|
||||
$this->translator->trans('duration') . ' (1:30)' => 'duration',
|
||||
$this->translator->trans('duration') . ' (1:30:00)' => 'duration_seconds',
|
||||
$this->translator->trans('duration') . ' (1.5)' => 'duration_decimal',
|
||||
'currency' => 'currency',
|
||||
'rate' => 'rate',
|
||||
'internalRate' => 'internal_rate',
|
||||
'hourlyRate' => 'hourly_rate',
|
||||
'fixedRate' => 'fixed_rate',
|
||||
'description' => 'description',
|
||||
'exported' => 'exported',
|
||||
'billable' => 'billable',
|
||||
'tags' => 'tags',
|
||||
'type' => 'type',
|
||||
'category' => 'category',
|
||||
],
|
||||
'user' => [
|
||||
'alias' => 'user.alias',
|
||||
'username' => 'user.name',
|
||||
'account_number' => 'user.account_number',
|
||||
],
|
||||
'customer' => [
|
||||
'customer' => 'customer.name',
|
||||
'number' => 'customer.number',
|
||||
'vat_id' => 'customer.vat_id',
|
||||
],
|
||||
'project' => [
|
||||
'project' => 'project.name',
|
||||
'project_number' => 'project.number',
|
||||
'orderNumber' => 'project.order_number',
|
||||
],
|
||||
'activity' => [
|
||||
'activity' => 'activity.name',
|
||||
'activity_number' => 'activity.number',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent(new TimesheetQuery(), TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['timesheet'][$metaField->getLabel()] = 'timesheet.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent(new CustomerQuery(), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['customer'][$metaField->getLabel()] = 'customer.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent(new ProjectQuery(), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['project'][$metaField->getLabel()] = 'project.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent(new ActivityQuery(), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['activity'][$metaField->getLabel()] = 'activity.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() !== null) {
|
||||
$columns['user'][$metaField->getLabel()] = 'user.meta.' . $metaField->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$resolver->setDefaults([
|
||||
'choices' => $columns,
|
||||
'label' => 'modal.columns.label',
|
||||
'multiple' => true,
|
||||
// does not work in the frontend
|
||||
//'order' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
$data = $event->getData();
|
||||
if (\is_array($data)) {
|
||||
$this->ordered = $data; // @phpstan-ignore assign.propertyType
|
||||
}
|
||||
}
|
||||
);
|
||||
$builder->addEventListener(
|
||||
FormEvents::SUBMIT,
|
||||
function (FormEvent $event) {
|
||||
$event->setData($this->ordered);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<MetaTableTypeInterface>
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
29
src/Form/Type/ExportRendererType.php
Normal file
29
src/Form/Type/ExportRendererType.php
Normal 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\Form\Type;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
final class ExportRendererType extends AbstractType
|
||||
{
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'choices' => ['button.csv' => 'csv', 'button.xlsx' => 'xlsx'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function getParent(): string
|
||||
{
|
||||
return ChoiceType::class;
|
||||
}
|
||||
}
|
||||
31
src/Repository/ExportTemplateRepository.php
Normal file
31
src/Repository/ExportTemplateRepository.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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\Repository;
|
||||
|
||||
use App\Entity\ExportTemplate;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
/**
|
||||
* @extends \Doctrine\ORM\EntityRepository<ExportTemplate>
|
||||
*/
|
||||
class ExportTemplateRepository extends EntityRepository
|
||||
{
|
||||
public function saveExportTemplate(ExportTemplate $template): void
|
||||
{
|
||||
$this->getEntityManager()->persist($template);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
public function removeExportTemplate(ExportTemplate $template): void
|
||||
{
|
||||
$this->getEntityManager()->remove($template);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
}
|
||||
24
src/Validator/Constraints/ExportRenderer.php
Normal file
24
src/Validator/Constraints/ExportRenderer.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
|
||||
#[\Attribute(\Attribute::TARGET_PROPERTY)]
|
||||
final class ExportRenderer extends Constraint
|
||||
{
|
||||
public const UNKNOWN_TYPE = 'kimai-export-type-00';
|
||||
|
||||
protected const ERROR_NAMES = [
|
||||
self::UNKNOWN_TYPE => 'Unknown exporter type.',
|
||||
];
|
||||
|
||||
public string $message = 'Unknown exporter type.';
|
||||
}
|
||||
41
src/Validator/Constraints/ExportRendererValidator.php
Normal file
41
src/Validator/Constraints/ExportRendererValidator.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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\Validator\Constraints;
|
||||
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
final class ExportRendererValidator extends ConstraintValidator
|
||||
{
|
||||
/**
|
||||
* @param string|mixed $value
|
||||
*/
|
||||
public function validate(mixed $value, Constraint $constraint): void
|
||||
{
|
||||
if (!($constraint instanceof ExportRenderer)) {
|
||||
throw new UnexpectedTypeException($constraint, ExportRenderer::class);
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ids = ['csv', 'xlsx'];
|
||||
|
||||
if (!\is_string($value) || !\in_array($value, $ids, true)) {
|
||||
$this->context->buildViolation(ExportRenderer::getErrorName(ExportRenderer::UNKNOWN_TYPE))
|
||||
->setParameter('{{ value }}', $this->formatValue($value))
|
||||
->setTranslationDomain('validators')
|
||||
->setCode(ExportRenderer::UNKNOWN_TYPE)
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user