support different formats in user timesheet exports (#1222)

This commit is contained in:
Kevin Papst
2019-11-08 16:10:38 +01:00
committed by GitHub
parent 0705c26513
commit fa1c79e15c
75 changed files with 1872 additions and 951 deletions

View File

@@ -17,6 +17,7 @@ use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
use App\Form\UserRolesType;
use App\Form\UserTeamsType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Voter\UserVoter;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
@@ -38,21 +39,21 @@ class ProfileController extends AbstractController
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
private $dispatcher;
/**
* @var UserPasswordEncoderInterface
*/
protected $encoder;
private $encoder;
/**
* @param UserPasswordEncoderInterface $encoder
* @param EventDispatcherInterface $dispatcher
* @var TeamRepository
*/
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher)
private $teams;
public function __construct(UserPasswordEncoderInterface $encoder, EventDispatcherInterface $dispatcher, TeamRepository $teams)
{
$this->encoder = $encoder;
$this->dispatcher = $dispatcher;
$this->teams = $teams;
}
/**
@@ -283,7 +284,7 @@ class ProfileController extends AbstractController
$apiTokenForm = $apiTokenForm ?: $this->createApiTokenForm($user);
$forms['api-token'] = $apiTokenForm->createView();
}
if ($this->isGranted(UserVoter::TEAMS, $user)) {
if ($this->isGranted(UserVoter::TEAMS, $user) && $this->teams->count([]) > 0) {
$teamsForm = $teamsForm ?: $this->createTeamsForm($user);
$forms['teams'] = $teamsForm->createView();
}

View File

@@ -15,6 +15,7 @@ use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Event\TimesheetMetaDefinitionEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\ServiceExport;
use App\Form\TimesheetEditForm;
use App\Form\Toolbar\TimesheetToolbarForm;
use App\Repository\ActivityRepository;
@@ -53,19 +54,25 @@ abstract class TimesheetAbstractController extends AbstractController
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var ServiceExport
*/
protected $exportService;
public function __construct(
UserDateTimeFactory $dateTime,
TimesheetConfiguration $configuration,
TimesheetRepository $repository,
TrackingModeService $service,
EventDispatcherInterface $dispatcher
EventDispatcherInterface $dispatcher,
ServiceExport $exportService
) {
$this->dateTime = $dateTime;
$this->configuration = $configuration;
$this->repository = $repository;
$this->trackingModeService = $service;
$this->dispatcher = $dispatcher;
$this->exportService = $exportService;
}
protected function getTrackingMode(): TrackingModeInterface
@@ -227,7 +234,7 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
protected function export(Request $request, string $renderTemplate, string $location): Response
protected function export(Request $request, string $exporterId): Response
{
$query = new TimesheetQuery();
@@ -252,11 +259,13 @@ abstract class TimesheetAbstractController extends AbstractController
$entries = $this->getRepository()->getTimesheetsForQuery($query);
return $this->render($renderTemplate, [
'entries' => $entries,
'query' => $query,
'metaColumns' => $this->findMetaColumns($query, $location),
]);
$exporter = $this->exportService->getTimesheetExporterById($exporterId);
if (null === $exporter) {
throw $this->createNotFoundException('Invalid timesheet exporter given');
}
return $exporter->render($entries, $query);
}
protected function prepareQuery(TimesheetQuery $query)

View File

@@ -16,6 +16,7 @@ use App\Repository\ProjectRepository;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -31,7 +32,7 @@ class TimesheetController extends TimesheetAbstractController
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @return Response
*/
public function indexAction($page, Request $request)
{
@@ -39,15 +40,16 @@ class TimesheetController extends TimesheetAbstractController
}
/**
* @Route(path="/export", name="timesheet_export", methods={"GET"})
* @Route(path="/export/{exporter}", name="timesheet_export", methods={"GET"})
* @Security("is_granted('export_own_timesheet')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @param string $exporter
* @return Response
*/
public function exportAction(Request $request)
public function exportAction(Request $request, string $exporter)
{
return $this->export($request, 'timesheet/export.html.twig', TimesheetMetaDisplayEvent::TIMESHEET_EXPORT);
return $this->export($request, $exporter);
}
/**
@@ -80,7 +82,7 @@ class TimesheetController extends TimesheetAbstractController
/**
* Used for the initial page rendering.
*
* @return \Symfony\Component\HttpFoundation\Response
* @return Response
*/
public function activeEntriesAction()
{

View File

@@ -18,6 +18,7 @@ use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
@@ -33,7 +34,7 @@ class TimesheetTeamController extends TimesheetAbstractController
*
* @param int $page
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @return Response
*/
public function indexAction($page, Request $request)
{
@@ -41,14 +42,15 @@ class TimesheetTeamController extends TimesheetAbstractController
}
/**
* @Route(path="/export", name="admin_timesheet_export", methods={"GET"})
* @Route(path="/export/{exporter}", name="admin_timesheet_export", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @param string $exporter
* @return Response
*/
public function exportAction(Request $request)
public function exportAction(Request $request, string $exporter)
{
return $this->export($request, 'timesheet-team/export.html.twig', TimesheetMetaDisplayEvent::TEAM_TIMESHEET_EXPORT);
return $this->export($request, $exporter);
}
/**

View File

@@ -0,0 +1,46 @@
<?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\DependencyInjection\Compiler;
use App\Export\ServiceExport;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all dependencies to the ExportService.
*/
class ExportServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceExport::class)) {
return;
}
$definition = $container->findDefinition(ServiceExport::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_EXPORT_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
$taggedExporter = $container->findTaggedServiceIds(Kernel::TAG_TIMESHEET_EXPORTER);
foreach ($taggedExporter as $id => $tags) {
$definition->addMethodCall('addTimesheetExporter', [new Reference($id)]);
}
}
}

View File

@@ -0,0 +1,407 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
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\TimesheetQuery;
use App\Twig\DateExtensions;
use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @internal
*/
abstract class AbstractSpreadsheetRenderer
{
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
public const TIME_FORMAT = 'hh:mm';
public const DURATION_FORMAT = '[hh]:mm';
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* \(#,##0.00\);_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT = self::RATE_FORMAT_LEFT;
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
/**
* @var AuthorizationCheckerInterface
*/
protected $voter;
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->dispatcher = $dispatcher;
$this->voter = $voter;
}
protected function isRenderRate(TimesheetQuery $query): bool
{
return true;
}
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyleByColumnAndRow($column, $row);
$style->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
if (null === $duration) {
$duration = 0;
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
}
protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency)
{
$sheet->setCellValueByColumnAndRow($column, $row, $rate);
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(
sprintf(self::RATE_FORMAT_LEFT, $currency)
);
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$showRates = $this->isRenderRate($query);
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
if ($showRates) {
$sheet->setCellValueByColumnAndRow(
$recordsHeaderColumn++,
$recordsHeaderRow,
$this->translator->trans('label.rate')
);
}
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags'));
if ($showRates) {
$sheet->setCellValueByColumnAndRow(
$recordsHeaderColumn++,
$recordsHeaderRow,
$this->translator->trans('label.hourlyRate')
);
$sheet->setCellValueByColumnAndRow(
$recordsHeaderColumn++,
$recordsHeaderRow,
$this->translator->trans('label.fixedRate')
);
}
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($userPreferences as $preference) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($preference->getLabel()));
}
$entryHeaderRow = $recordsHeaderRow + 1;
$durationColumn = null;
$rateColumn = null;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd());
$entryHeaderColumn++;
$this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration());
$durationColumn = $entryHeaderColumn;
$entryHeaderColumn++;
if ($showRates) {
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency);
$rateColumn = $entryHeaderColumn;
$entryHeaderColumn++;
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getUser()->getDisplayName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
$entryHeaderColumn++;
if ($showRates) {
$this->setRate(
$sheet,
$entryHeaderColumn,
$entryHeaderRow,
$timesheet->getHourlyRate(),
$customerCurrency
);
$entryHeaderColumn++;
$this->setRate(
$sheet,
$entryHeaderColumn,
$entryHeaderRow,
$timesheet->getFixedRate(),
$customerCurrency
);
$entryHeaderColumn++;
}
foreach ($timesheetMetaFields as $metaField) {
$metaField = $timesheet->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($customerMetaFields as $metaField) {
$metaField = $timesheet->getProject()->getCustomer()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($projectMetaFields as $metaField) {
$metaField = $timesheet->getProject()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($activityMetaFields as $metaField) {
$metaField = $timesheet->getActivity()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($userPreferences as $preference) {
$metaField = $timesheet->getUser()->getPreference($preference->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
$entryHeaderRow++;
}
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
return $spreadsheet;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query);
$filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension());
}
/**
* @return string
*/
abstract public function getFileExtension(): string;
/**
* @param mixed $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse($file, $filename): Response
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
/**
* @return string
*/
abstract protected function getContentType(): string;
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
class CsvRenderer extends AbstractSpreadsheetRenderer
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
public function getId(): string
{
return 'csv';
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration));
}
}

View File

@@ -0,0 +1,95 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
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\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
class HtmlRenderer
{
use RendererTrait;
/**
* @var Environment
*/
protected $twig;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher)
{
$this->twig = $twig;
$this->dispatcher = $dispatcher;
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets,
'query' => $query,
'summaries' => $this->calculateSummary($timesheets),
'timesheetMetaFields' => $timesheetMetaFields,
'customerMetaFields' => $customerMetaFields,
'projectMetaFields' => $projectMetaFields,
'activityMetaFields' => $activityMetaFields,
'userPreferences' => $userPreferences,
]);
$response = new Response();
$response->setContent($content);
return $response;
}
public function getId(): string
{
return 'html';
}
}

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\HtmlToPdfConverter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Twig\Environment;
class PDFRenderer
{
use RendererTrait;
/**
* @var Environment
*/
private $twig;
/**
* @var UserDateTimeFactory
*/
private $dateTime;
/**
* @var HtmlToPdfConverter
*/
private $converter;
public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter)
{
$this->twig = $twig;
$this->dateTime = $dateTime;
$this->converter = $converter;
}
protected function getTemplate(): string
{
return 'export/renderer/pdf.html.twig';
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render($this->getTemplate(), [
'entries' => $timesheets,
'query' => $query,
'now' => $this->dateTime->createDateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);
$content = $this->converter->convertToPdf($content);
$response = new Response($content);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf');
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
public function getId(): string
{
return 'pdf';
}
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use App\Entity\Timesheet;
trait RendererTrait
{
/**
* @param Timesheet[] $timesheets
* @return array
*/
protected function calculateSummary(array $timesheets)
{
$summary = [];
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId();
$activityId = $timesheet->getActivity()->getId();
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(),
'project' => $timesheet->getProject()->getName(),
'activities' => [],
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
if (!isset($summary[$id]['activities'][$activityId])) {
$summary[$id]['activities'][$activityId] = [
'activity' => $timesheet->getActivity()->getName(),
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
$duration = $timesheet->getDuration();
if (null === $duration) {
$duration = 0;
}
$summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $duration;
$summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate();
$summary[$id]['activities'][$activityId]['duration'] += $duration;
}
asort($summary);
return $summary;
}
}

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Base;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer
{
public function getFileExtension(): string
{
return '.xlsx';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
public function getId(): string
{
return 'xlsx';
}
}

View File

@@ -9,361 +9,11 @@
namespace App\Export\Renderer;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
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\TimesheetQuery;
use App\Twig\DateExtensions;
use DateTime;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Contracts\Translation\TranslatorInterface;
use App\Export\Base\AbstractSpreadsheetRenderer as BaseAbstractSpreadsheetRenderer;
abstract class AbstractSpreadsheetRenderer
/**
* @deprecated since 1.6, will be removed with 2.0
*/
abstract class AbstractSpreadsheetRenderer extends BaseAbstractSpreadsheetRenderer
{
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
public const TIME_FORMAT = 'hh:mm';
public const DURATION_FORMAT = '[hh]:mm';
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* \(#,##0.00\);_("%1$s"* "-"??_);_(@_)';
public const RATE_FORMAT = self::RATE_FORMAT_LEFT;
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, EventDispatcherInterface $dispatcher)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->dispatcher = $dispatcher;
}
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
}
protected function setFormattedDate(Worksheet $sheet, $column, $row, ?DateTime $date)
{
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
return;
}
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD2);
}
protected function setDurationTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
$style = $sheet->getStyleByColumnAndRow($column, $row);
$style->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
if (null === $duration) {
$duration = 0;
}
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s/86400', $duration));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DURATION_FORMAT);
}
protected function setRateTotal(Worksheet $sheet, $column, $row, $startCoordinate, $endCoordinate)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=SUM(%s:%s)', $startCoordinate, $endCoordinate));
}
protected function setRate(Worksheet $sheet, $column, $row, $rate, $currency)
{
$sheet->setCellValueByColumnAndRow($column, $row, $rate);
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(
sprintf(self::RATE_FORMAT_LEFT, $currency)
);
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.date'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.begin'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.end'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.user'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.customer'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.project'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.activity'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.description'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.exported'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.tags'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate'));
foreach ($timesheetMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($customerMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($projectMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($activityMetaFields as $metaField) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaField->getLabel()));
}
foreach ($userPreferences as $preference) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($preference->getLabel()));
}
$entryHeaderRow = $recordsHeaderRow + 1;
$durationColumn = null;
$rateColumn = null;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$this->setFormattedDate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getBegin());
$entryHeaderColumn++;
$this->setFormattedTime($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getEnd());
$entryHeaderColumn++;
$this->setDuration($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getDuration());
$durationColumn = $entryHeaderColumn;
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getRate(), $customerCurrency);
$rateColumn = $entryHeaderColumn;
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->getUsername($timesheet));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getProject()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getActivity()->getName());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $timesheet->getDescription());
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, $this->translator->trans($exported));
$entryHeaderColumn++;
$sheet->setCellValueByColumnAndRow($entryHeaderColumn, $entryHeaderRow, implode(',', $timesheet->getTagsAsArray()));
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getHourlyRate(), $customerCurrency);
$entryHeaderColumn++;
$this->setRate($sheet, $entryHeaderColumn, $entryHeaderRow, $timesheet->getFixedRate(), $customerCurrency);
$entryHeaderColumn++;
foreach ($timesheetMetaFields as $metaField) {
$metaField = $timesheet->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($customerMetaFields as $metaField) {
$metaField = $timesheet->getProject()->getCustomer()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($projectMetaFields as $metaField) {
$metaField = $timesheet->getProject()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($activityMetaFields as $metaField) {
$metaField = $timesheet->getActivity()->getMetaField($metaField->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
foreach ($userPreferences as $preference) {
$metaField = $timesheet->getUser()->getPreference($preference->getName());
$metaFieldValue = '';
if (null !== $metaField) {
$metaFieldValue = $metaField->getValue();
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
$entryHeaderRow++;
}
if (null !== $durationColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($durationColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($durationColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($durationColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
if (null !== $rateColumn) {
$startCoordinate = $sheet->getCellByColumnAndRow($rateColumn, 2)->getCoordinate();
$endCoordinate = $sheet->getCellByColumnAndRow($rateColumn, $entryHeaderRow - 1)->getCoordinate();
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
$style = $sheet->getStyleByColumnAndRow($rateColumn, $entryHeaderRow);
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$style->getFont()->setBold(true);
}
return $spreadsheet;
}
protected function getUsername(Timesheet $timesheet): string
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
}
return $timesheet->getUser()->getUsername();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \PhpOffice\PhpSpreadsheet\Exception
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$spreadsheet = $this->fromArrayToSpreadsheet($timesheets, $query);
$filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, 'kimai-export' . $this->getFileExtension());
}
/**
* @return string
*/
abstract public function getFileExtension(): string;
/**
* @param mixed $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse($file, $filename): Response
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
/**
* @return string
*/
abstract protected function getContentType(): string;
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -9,73 +9,18 @@
namespace App\Export\Renderer;
use App\Export\Base\CsvRenderer as BaseCsvRenderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
final class CsvRenderer extends BaseCsvRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'csv';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'csv';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'csv';
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration));
}
}

View File

@@ -9,105 +9,16 @@
namespace App\Export\Renderer;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Timesheet;
use App\Event\ActivityMetaDisplayEvent;
use App\Event\CustomerMetaDisplayEvent;
use App\Event\MetaDisplayEventInterface;
use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\Base\HtmlRenderer as BaseHtmlRenderer;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
final class HtmlRenderer implements RendererInterface
final class HtmlRenderer extends BaseHtmlRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var Environment
*/
protected $twig;
/**
* @var EventDispatcherInterface
*/
protected $dispatcher;
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher)
{
$this->twig = $twig;
$this->dispatcher = $dispatcher;
}
/**
* @param MetaDisplayEventInterface $event
* @return MetaTableTypeInterface[]
*/
protected function findMetaColumns(MetaDisplayEventInterface $event): array
{
$this->dispatcher->dispatch($event);
return $event->getFields();
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);
$userPreferences = $event->getPreferences();
$content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets,
'query' => $query,
'summaries' => $this->calculateSummary($timesheets),
'timesheetMetaFields' => $timesheetMetaFields,
'customerMetaFields' => $customerMetaFields,
'projectMetaFields' => $projectMetaFields,
'activityMetaFields' => $activityMetaFields,
'userPreferences' => $userPreferences,
]);
$response = new Response();
$response->setContent($content);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'html';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'print';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'print';

View File

@@ -9,92 +9,16 @@
namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\Base\PDFRenderer as BasePDFRenderer;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\HtmlToPdfConverter;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Twig\Environment;
final class PDFRenderer implements RendererInterface
final class PDFRenderer extends BasePDFRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var Environment
*/
protected $twig;
/**
* @var UserDateTimeFactory
*/
protected $dateTime;
/**
* @var HtmlToPdfConverter
*/
protected $converter;
/**
* @param Environment $twig
* @param UserDateTimeFactory $dateTime
* @param HtmlToPdfConverter $converter
*/
public function __construct(Environment $twig, UserDateTimeFactory $dateTime, HtmlToPdfConverter $converter)
{
$this->twig = $twig;
$this->dateTime = $dateTime;
$this->converter = $converter;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/pdf.html.twig', [
'entries' => $timesheets,
'query' => $query,
'now' => $this->dateTime->createDateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);
$content = $this->converter->convertToPdf($content);
$response = new Response($content);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'kimai-export.pdf');
$response->headers->set('Content-Type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'pdf';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'pdf';

View File

@@ -9,55 +9,12 @@
namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\Base\RendererTrait as BaseRendererTrait;
/**
* @deprecated since 1.6, will be removed with 2.0
*/
trait RendererTrait
{
/**
* @param Timesheet[] $timesheets
* @return array
*/
protected function calculateSummary(array $timesheets)
{
$summary = [];
foreach ($timesheets as $timesheet) {
$id = $timesheet->getProject()->getCustomer()->getId() . '_' . $timesheet->getProject()->getId();
$activityId = $timesheet->getActivity()->getId();
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(),
'project' => $timesheet->getProject()->getName(),
'activities' => [],
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
if (!isset($summary[$id]['activities'][$activityId])) {
$summary[$id]['activities'][$activityId] = [
'activity' => $timesheet->getActivity()->getName(),
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
$duration = $timesheet->getDuration();
if (null === $duration) {
$duration = 0;
}
$summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $duration;
$summary[$id]['activities'][$activityId]['rate'] += $timesheet->getRate();
$summary[$id]['activities'][$activityId]['duration'] += $duration;
}
asort($summary);
return $summary;
}
use BaseRendererTrait;
}

View File

@@ -9,65 +9,16 @@
namespace App\Export\Renderer;
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
final class XlsxRenderer extends BaseXlsxRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.xlsx';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'xlsx';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'xlsx';

View File

@@ -9,22 +9,17 @@
namespace App\Export;
class ServiceExport
final class ServiceExport
{
/**
* @var RendererInterface[]
*/
protected $renderer = [];
private $renderer = [];
/**
* @param RendererInterface[] $renderer
* @var TimesheetExportInterface[]
*/
public function __construct(iterable $renderer)
{
foreach ($renderer as $render) {
$this->addRenderer($render);
}
}
private $exporter = [];
public function addRenderer(RendererInterface $renderer): ServiceExport
{
@@ -51,4 +46,30 @@ class ServiceExport
return null;
}
public function addTimesheetExporter(TimesheetExportInterface $exporter): ServiceExport
{
$this->exporter[] = $exporter;
return $this;
}
/**
* @return TimesheetExportInterface[]
*/
public function getTimesheetExporter(): array
{
return $this->exporter;
}
public function getTimesheetExporterById(string $id): ?TimesheetExportInterface
{
foreach ($this->exporter as $exporter) {
if ($exporter->getId() === $id) {
return $exporter;
}
}
return null;
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Timesheet;
use App\Export\Base\CsvRenderer as BaseCsvRenderer;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
final class CsvRenderer extends BaseCsvRenderer implements TimesheetExportInterface
{
protected function isRenderRate(TimesheetQuery $query): bool
{
if (null !== $query->getUser()) {
return $this->voter->isGranted('view_rate_own_timesheet');
}
return true;
}
}

View File

@@ -0,0 +1,75 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Timesheet;
use App\Entity\Timesheet;
use App\Event\TimesheetMetaDisplayEvent;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
final class HtmlRenderer implements TimesheetExportInterface
{
/**
* @var Environment
*/
private $twig;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
public function __construct(Environment $twig, EventDispatcherInterface $dispatcher)
{
$this->twig = $twig;
$this->dispatcher = $dispatcher;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$metaLocation = TimesheetMetaDisplayEvent::TEAM_TIMESHEET_EXPORT;
if (null !== $query->getUser()) {
$metaLocation = TimesheetMetaDisplayEvent::EXPORT;
}
$event = new TimesheetMetaDisplayEvent($query, $metaLocation);
$this->dispatcher->dispatch($event);
$timesheetMetaFields = $event->getFields();
$content = $this->twig->render('timesheet/export.html.twig', [
'entries' => $timesheets,
'query' => $query,
'metaColumns' => $timesheetMetaFields,
]);
$response = new Response();
$response->setContent($content);
return $response;
}
/**
* @return string
*/
public function getId(): string
{
return 'print';
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Timesheet;
use App\Export\Base\PDFRenderer as BasePDFRenderer;
use App\Export\TimesheetExportInterface;
final class PDFRenderer extends BasePDFRenderer implements TimesheetExportInterface
{
}

View File

@@ -0,0 +1,22 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Timesheet;
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
use App\Export\TimesheetExportInterface;
use App\Repository\Query\TimesheetQuery;
final class XlsxRenderer extends BaseXlsxRenderer implements TimesheetExportInterface
{
protected function isRenderRate(TimesheetQuery $query): bool
{
return false;
}
}

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;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
interface TimesheetExportInterface
{
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $timesheets, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
}

View File

@@ -11,10 +11,12 @@ namespace App;
use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\DependencyInjection\Compiler\WidgetCompilerPass;
use App\Export\RendererInterface as ExportRendererInterface;
use App\Export\TimesheetExportInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGeneratorInterface;
@@ -49,6 +51,7 @@ class Kernel extends BaseKernel
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public const TAG_INVOICE_REPOSITORY = 'invoice.repository';
public const TAG_TIMESHEET_CALCULATOR = 'timesheet.calculator';
public const TAG_TIMESHEET_EXPORTER = 'timesheet.exporter';
public function getCacheDir()
{
@@ -71,6 +74,7 @@ class Kernel extends BaseKernel
$container->registerForAutoconfiguration(PluginInterface::class)->addTag(self::TAG_PLUGIN);
$container->registerForAutoconfiguration(WidgetRendererInterface::class)->addTag(self::TAG_WIDGET_RENDERER);
$container->registerForAutoconfiguration(WidgetInterface::class)->addTag(self::TAG_WIDGET);
$container->registerForAutoconfiguration(TimesheetExportInterface::class)->addTag(self::TAG_TIMESHEET_EXPORTER);
/** @var SecurityExtension $extension */
$extension = $container->getExtension('security');
@@ -91,6 +95,10 @@ class Kernel extends BaseKernel
return;
}
if ($this->environment === 'test' && getenv('TEST_WITH_BUNDLES') === false) {
return;
}
$finder = new Finder();
$finder->ignoreUnreadableDirs()->directories()->name('*Bundle');
/** @var SplFileInfo $bundleDir */
@@ -149,6 +157,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new ExportServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new WidgetCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
}
@@ -172,6 +181,10 @@ class Kernel extends BaseKernel
// load application routes
$routes->import($confDir . '/routes' . self::CONFIG_EXTS, '/', 'glob');
if ($this->environment === 'test' && getenv('TEST_WITH_BUNDLES') === false) {
return;
}
// load plugin routes
$pluginsDir = $this->getProjectDir() . '/var/plugins';
if (!file_exists($pluginsDir)) {

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Twig;
use App\Export\ServiceExport;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class TimesheetExtension extends AbstractExtension
{
/**
* @var ServiceExport
*/
private $service;
public function __construct(ServiceExport $service)
{
$this->service = $service;
}
/**
* {@inheritdoc}
*/
public function getFunctions()
{
return [
new TwigFunction('timesheet_exporter', [$this, 'getTimesheetExporter'], []),
];
}
public function getTimesheetExporter(): array
{
$ids = [];
foreach ($this->service->getTimesheetExporter() as $exporter) {
$ids[] = $exporter->getId();
}
return $ids;
}
}