added export module (#538)

This commit is contained in:
Kevin Papst
2019-02-05 21:37:33 +01:00
committed by GitHub
parent 062735c9e3
commit 816866549c
104 changed files with 3770 additions and 608 deletions

View File

@@ -169,6 +169,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]);
$form->setData($timesheet);
@@ -216,4 +217,64 @@ class TimesheetController extends BaseApiController
return $this->viewHandler->handle($view);
}
/**
* @SWG\Post(
* description="Update an existing timesheet entry, you can pass all or just a subset of all attributes",
* @SWG\Schema(ref="#/definitions/TimesheetFormEntity"),
* @SWG\Response(
* response=200,
* description="Returns the updated timesheet entry",
* @SWG\Schema(ref="#/definitions/TimesheetEntity"),
* )
* )
*
* @param Request $request
* @param string $id
* @return Response
*/
public function patchAction(Request $request, string $id)
{
$timesheet = $this->repository->find($id);
if (!$this->isGranted('edit', $timesheet)) {
throw $this->createAccessDeniedException('User cannot update timesheet');
}
$form = $this->createForm(TimesheetEditForm::class, $timesheet, [
'csrf_protection' => false,
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
]);
$form->setData($timesheet);
$form->submit($request->request->all(), false);
if (false === $form->isValid()) {
$view = new View($form, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
if ($form->has('duration')) {
$duration = $form->get('duration')->getData();
if ($duration > 0) {
/** @var Timesheet $record */
$record = $form->getData();
$end = clone $record->getBegin();
$end->modify('+ ' . $duration . 'seconds');
$record->setEnd($end);
}
}
$entityManager = $this->getDoctrine()->getManager();
$entityManager->persist($timesheet);
$entityManager->flush();
$view = new View($timesheet, Response::HTTP_OK);
$view->getContext()->setGroups(['Default', 'Entity', 'Timesheet']);
return $this->viewHandler->handle($view);
}
}

View File

@@ -848,7 +848,7 @@ class KimaiImporterCommand extends Command
* ["description"]=> NULL
* ["comment"]=> string(36) "a work description"
* -- ["commentType"]=> string(1) "0"
* -- ["cleared"]=> string(1) "0"
* ["cleared"]=> string(1) "0"
* -- ["location"]=> string(0) ""
* -- ["trackingNumber"]=> NULL
* ["rate"]=> string(5) "50.00"
@@ -936,6 +936,7 @@ class KimaiImporterCommand extends Command
->setDuration($duration)
->setActivity($activity)
->setProject($project)
->setExported(intval($oldRecord['cleared']) !== 0)
;
if (!$this->validateImport($io, $timesheet)) {

View File

@@ -14,6 +14,10 @@ namespace App;
*/
class Constants
{
/**
* The software name
*/
public const SOFTWARE = 'Kimai 2';
/**
* The current release version
*/

View File

@@ -19,7 +19,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used for executing system relevant tasks.
*
* @Route(path="/admin/about")
* @Security("is_granted('ROLE_SUPER_ADMIN')")
* @Security("is_granted('system_information')")
*/
class AboutController extends AbstractController
{

View File

@@ -189,6 +189,7 @@ class TimesheetController extends AbstractController
'page' => $page,
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => true,
]);
}

View File

@@ -0,0 +1,162 @@
<?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\Controller;
use App\Entity\Timesheet;
use App\Export\ServiceExport;
use App\Form\Toolbar\ExportToolbarForm;
use App\Repository\Query\ExportQuery;
use App\Repository\TimesheetRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('view_export')")
*/
class ExportController extends AbstractController
{
/**
* @var TimesheetRepository
*/
protected $timesheetRepository;
/**
* @var ServiceExport
*/
protected $export;
/**
* @param TimesheetRepository $timesheet
* @param ServiceExport $export
*/
public function __construct(TimesheetRepository $timesheet, ServiceExport $export)
{
$this->timesheetRepository = $timesheet;
$this->export = $export;
}
/**
* @return ExportQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$query = new ExportQuery();
$query->setOrder(ExportQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(ExportQuery::STATE_STOPPED);
$query->setExported(ExportQuery::STATE_NOT_EXPORTED);
return $query;
}
/**
* @Route(path="/", name="export", methods={"GET", "POST"})
* @Security("is_granted('view_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function indexAction(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$entries = $this->getEntries($query);
return $this->render('export/index.html.twig', [
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
]);
}
/**
* @Route(path="/data", name="export_data", methods={"GET", "POST"})
* @Security("is_granted('create_export')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function export(Request $request)
{
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var ExportQuery $query */
$query = $form->getData();
}
$type = $query->getType();
if (null === $type) {
throw $this->createNotFoundException('Missing export renderer');
}
$renderer = $this->export->getRendererById($type);
// this code should not be reached, as the query already filters invalid values
// when trying to call setType() with an unknown value
if (null === $renderer) {
throw $this->createNotFoundException('Invalid export renderer');
}
$entries = $this->getEntries($query);
return $renderer->render($entries, $query);
}
/**
* @param ExportQuery $query
* @return Timesheet[]
*/
protected function getEntries(ExportQuery $query)
{
$query->setResultType(ExportQuery::RESULT_TYPE_QUERYBUILDER);
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->timesheetRepository->findByQuery($query);
return $queryBuilder->getQuery()->getResult();
}
/**
* @param ExportQuery $query
* @return \Symfony\Component\Form\FormInterface
*/
protected function getToolbarForm(ExportQuery $query)
{
return $this->createForm(ExportToolbarForm::class, $query, [
'action' => $this->generateUrl('export', []),
'method' => 'POST',
'attr' => [
'id' => 'export-form'
]
]);
}
}

View File

@@ -269,6 +269,7 @@ class TimesheetController extends AbstractController
'origin' => $redirectRoute,
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
]);
}

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

View File

@@ -177,6 +177,7 @@ class Configuration implements ConfigurationInterface
->scalarNode('date_type')->defaultValue('yyyy-MM-dd')->end() // for DateType
->scalarNode('date_picker')->defaultValue('YYYY-MM-DD')->end() // for DateType JS component
->scalarNode('date')->defaultValue('Y-m-d')->end() // for display via twig
->scalarNode('date_time')->defaultValue('m-d H:i')->end() // for display via twig
->scalarNode('duration')->defaultValue('%%h:%%m h')->end() // for display via twig
->end()
->end()

View File

@@ -119,6 +119,14 @@ class Timesheet
*/
private $hourlyRate = null;
/**
* @var bool
*
* @ORM\Column(name="exported", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $exported = false;
/**
* Get entry id
*
@@ -345,6 +353,25 @@ class Timesheet
return $this;
}
/**
* @return bool
*/
public function isExported(): bool
{
return $this->exported;
}
/**
* @param bool $exported
* @return Timesheet
*/
public function setExported(bool $exported)
{
$this->exported = $exported;
return $this;
}
/**
* @param ExecutionContextInterface $context
* @param $payload

View File

@@ -70,6 +70,12 @@ class MenuSubscriber implements EventSubscriberInterface
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], 'fas fa-file-invoice')
);
}
if ($auth->isGranted('view_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], 'fas fa-file-export')
);
}
}
/**

View File

@@ -0,0 +1,215 @@
<?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\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractSpreadsheetRenderer
{
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var Extensions
*/
protected $extension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
* @param DateExtensions $dateExtension
* @param Extensions $extensions
*/
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
{
return $this->dateExtension->dateShort($date) . ' ' . date('H:i', $date->getTimestamp());
}
/**
* @param $amount
* @return mixed
*/
protected function getFormattedMoney($amount, $currency)
{
return $this->extension->money($amount, $currency);
}
/**
* @param Timesheet $timesheet
* @return string
*/
protected function getUsername(Timesheet $timesheet)
{
if (!empty($timesheet->getUser()->getAlias())) {
return $timesheet->getUser()->getAlias();
}
return $timesheet->getUser()->getUsername();
}
/**
* @param $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
{
return $this->extension->duration($seconds);
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Spreadsheet
* @throws \PhpOffice\PhpSpreadsheet\Exception
*/
protected function fromArrayToSpreadsheet(array $timesheets, TimesheetQuery $query): Spreadsheet
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$recordsHeaderColumn = 1;
$recordsHeaderRow = 1;
$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.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.hourly_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixed_rate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.duration'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.rate'));
$entryHeaderRow = $recordsHeaderRow + 1;
$durationTotal = 0;
$currency = false;
$rateTotal = 0;
foreach ($timesheets as $timesheet) {
$entryHeaderColumn = 1;
$durationTotal += $timesheet->getDuration();
$rateTotal += $timesheet->getRate();
if ($currency === false) {
$currency = $timesheet->getProject()->getCustomer()->getCurrency();
}
if ($currency !== $timesheet->getProject()->getCustomer()->getCurrency()) {
$currency = null;
}
$customerCurrency = $timesheet->getProject()->getCustomer()->getCurrency();
$exported = $timesheet->isExported() ? 'entryState.exported' : 'entryState.not_exported';
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getBegin()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDateTime($timesheet->getEnd()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getUsername($timesheet));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getCustomer()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getProject()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getActivity()->getName());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $timesheet->getDescription());
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->translator->trans($exported));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getHourlyRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getFixedRate(), $customerCurrency));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedDuration($timesheet->getDuration()));
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $this->getFormattedMoney($timesheet->getRate(), $customerCurrency));
$entryHeaderRow++;
}
$sheet->setCellValueByColumnAndRow(11, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->setCellValueByColumnAndRow(12, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(11, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(\PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN);
$sheet->getCellByColumnAndRow(12, $entryHeaderRow)->getStyle()->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 \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
}

View File

@@ -0,0 +1,71 @@
<?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\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.csv';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-csv');
$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';
}
}

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\HttpFoundation\Response;
class HtmlRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/default.html.twig', [
'entries' => $timesheets,
'query' => $query,
'summaries' => $this->calculateSummary($timesheets),
]);
$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

@@ -0,0 +1,71 @@
<?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\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string
*/
public function getFileExtension(): string
{
return '.ods';
}
/**
* @return string
*/
protected function getContentType(): string
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);
return $filename;
}
/**
* @return string
*/
public function getId(): string
{
return 'ods';
}
/**
* @return string
*/
public function getIcon(): string
{
return 'ods';
}
/**
* @return string
*/
public function getTitle(): string
{
return 'ods';
}
}

View File

@@ -0,0 +1,94 @@
<?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\Entity\Timesheet;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use Mpdf\Mpdf;
use Mpdf\Output\Destination;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
class PDFRenderer implements RendererInterface
{
use RendererTrait;
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
* @throws \Mpdf\MpdfException
* @throws \Twig_Error_Loader
* @throws \Twig_Error_Runtime
* @throws \Twig_Error_Syntax
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
$content = $this->twig->render('export/renderer/pdf.html.twig', [
'entries' => $timesheets,
'query' => $query,
'now' => new \DateTime(),
'summaries' => $this->calculateSummary($timesheets),
]);
//return new Response($content);
$mpdf = new Mpdf();
$mpdf->WriteHTML($content);
$content = $mpdf->Output('test', Destination::STRING_RETURN);
$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

@@ -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\Export\Renderer;
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();
if (!isset($summary[$id])) {
$summary[$id] = [
'customer' => $timesheet->getProject()->getCustomer()->getName(),
'project' => $timesheet->getProject()->getName(),
'currency' => $timesheet->getProject()->getCustomer()->getCurrency(),
'rate' => 0,
'duration' => 0,
];
}
$summary[$id]['rate'] += $timesheet->getRate();
$summary[$id]['duration'] += $timesheet->getDuration();
}
asort($summary);
return $summary;
}
}

View File

@@ -0,0 +1,71 @@
<?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\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer 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 bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
$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

@@ -0,0 +1,39 @@
<?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 RendererInterface
{
/**
* @param Timesheet[] $timesheets
* @param TimesheetQuery $query
* @return Response
*/
public function render(array $timesheets, TimesheetQuery $query): Response;
/**
* @return string
*/
public function getId(): string;
/**
* @return string
*/
public function getIcon(): string;
/**
* @return string
*/
public function getTitle(): string;
}

View File

@@ -0,0 +1,54 @@
<?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;
class ServiceExport
{
/**
* @var RendererInterface[]
*/
protected $renderer = [];
/**
* @param RendererInterface $renderer
* @return $this
*/
public function addRenderer(RendererInterface $renderer)
{
$this->renderer[] = $renderer;
return $this;
}
/**
* Returns an array of export renderer.
*
* @return RendererInterface[]
*/
public function getRenderer()
{
return $this->renderer;
}
/**
* @param string $id
* @return RendererInterface|null
*/
public function getRendererById(string $id)
{
foreach ($this->renderer as $renderer) {
if ($renderer->getId() === $id) {
return $renderer;
}
}
return null;
}
}

View File

@@ -16,6 +16,7 @@ use App\Form\Type\DateTimePickerType;
use App\Form\Type\DurationType;
use App\Form\Type\ProjectType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
@@ -226,6 +227,12 @@ class TimesheetEditForm extends AbstractType
if ($options['include_user']) {
$builder->add('user', UserType::class);
}
if ($options['include_exported']) {
$builder->add('exported', YesNoType::class, [
'label' => 'label.exported'
]);
}
}
/**
@@ -240,6 +247,7 @@ class TimesheetEditForm extends AbstractType
'csrf_token_id' => 'timesheet_edit',
'duration_only' => $this->durationOnly,
'include_user' => false,
'include_exported' => false,
'include_rate' => true,
'docu_chapter' => 'timesheet',
'method' => 'POST',

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Form\Toolbar;
use App\Repository\Query\ExportQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Defines the form used for filtering timesheet entries for exports.
*/
class ExportToolbarForm extends AbstractToolbarForm
{
/**
* {@inheritdoc}
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addExportStateChoice($builder);
$this->addUserChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addExportType($builder);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportType(FormBuilderInterface $builder)
{
$builder->add('type', HiddenType::class, []);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addExportStateChoice(FormBuilderInterface $builder)
{
$builder->add('exported', ChoiceType::class, [
'label' => 'label.exported',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.exported' => TimesheetQuery::STATE_EXPORTED,
'entryState.not_exported' => TimesheetQuery::STATE_NOT_EXPORTED
],
]);
}
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ExportQuery::class,
'csrf_protection' => false,
]);
}
}

View File

@@ -25,7 +25,7 @@ class YesNoType extends AbstractType
{
$resolver->setDefaults([
'value' => true,
'false_values' => [null, 0, false],
'false_values' => [null, 0, false, 'false'],
'required' => false,
]);
}

View File

@@ -38,7 +38,7 @@ class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-csv');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);

View File

@@ -38,7 +38,7 @@ class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterfa
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-ods');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);

View File

@@ -38,7 +38,7 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterf
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-xslx');
$filename = tempnam(sys_get_temp_dir(), 'kimai-invoice-xlsx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);

View File

@@ -11,11 +11,13 @@ 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\Export\RendererInterface as ExportRendererInterface;
use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Invoice\RendererInterface as InvoiceRendererInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface;
@@ -30,6 +32,7 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_EXPORT_RENDERER = 'export.renderer';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
@@ -47,7 +50,8 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container)
{
$container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator');
$container->registerForAutoconfiguration(RendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(ExportRendererInterface::class)->addTag(self::TAG_EXPORT_RENDERER);
$container->registerForAutoconfiguration(InvoiceRendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
}
@@ -80,6 +84,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);
}
protected function configureRoutes(RouteCollectionBuilder $routes)

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
/*
* 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 DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* Adds the exported column to the timesheet table
*/
final class Version20190124004014 extends AbstractMigration
{
public function up(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD COLUMN exported BOOLEAN NOT NULL DEFAULT false');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' ADD exported TINYINT(1) NOT NULL DEFAULT false');
}
}
public function down(Schema $schema): void
{
$platform = $this->getPlatform();
if (!in_array($platform, ['sqlite', 'mysql'])) {
$this->abortIf(true, 'Unsupported database platform: ' . $platform);
}
$timesheet = $this->getTableName('timesheet');
if ($platform === 'sqlite') {
$this->addSql('DROP INDEX IDX_4F60C6B1166D1F9C');
$this->addSql('DROP INDEX IDX_4F60C6B18D93D649');
$this->addSql('DROP INDEX IDX_4F60C6B181C06096');
$this->addSql('CREATE TEMPORARY TABLE __temp__' . $timesheet . ' AS SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM ' . $timesheet);
$this->addSql('DROP TABLE ' . $timesheet);
$this->addSql('CREATE TABLE ' . $timesheet . ' (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, user INTEGER NOT NULL, activity_id INTEGER NOT NULL, project_id INTEGER NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME DEFAULT NULL, duration INTEGER DEFAULT NULL, description CLOB DEFAULT NULL, rate NUMERIC(10, 2) NOT NULL, fixed_rate NUMERIC(10, 2) DEFAULT NULL, hourly_rate NUMERIC(10, 2) DEFAULT NULL)');
$this->addSql('INSERT INTO ' . $timesheet . ' (id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate) SELECT id, user, activity_id, project_id, start_time, end_time, duration, description, rate, fixed_rate, hourly_rate FROM __temp__' . $timesheet);
$this->addSql('DROP TABLE __temp__' . $timesheet);
$this->addSql('CREATE INDEX IDX_4F60C6B1166D1F9C ON ' . $timesheet . ' (project_id)');
$this->addSql('CREATE INDEX IDX_4F60C6B18D93D649 ON ' . $timesheet . ' (user)');
$this->addSql('CREATE INDEX IDX_4F60C6B181C06096 ON ' . $timesheet . ' (activity_id)');
} else {
$this->addSql('ALTER TABLE ' . $timesheet . ' DROP exported');
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
/**
* Can be used for export queries.
*/
class ExportQuery extends TimesheetQuery
{
public const TYPE_HTML = 'html';
public const TYPE_CSV = 'csv';
public const TYPE_PDF = 'pdf';
public const TYPE_XLSX = 'xlsx';
public const TYPE_ODS = 'ods';
/**
* @var string
*/
protected $type;
/**
* @return string
*/
public function getType(): ?string
{
return $this->type;
}
/**
* @param string $type
* @return ExportQuery
*/
public function setType(string $type)
{
if (in_array($type, [self::TYPE_PDF, self::TYPE_CSV, self::TYPE_HTML, self::TYPE_XLSX, self::TYPE_ODS])) {
$this->type = $type;
}
return $this;
}
}

View File

@@ -22,6 +22,8 @@ class InvoiceQuery extends TimesheetQuery
protected $template;
/**
* TODO can this be removed ???
*
* @var InvoiceTemplate[]
*/
protected $templates = [];

View File

@@ -21,6 +21,8 @@ class TimesheetQuery extends ActivityQuery
public const STATE_ALL = 1;
public const STATE_RUNNING = 2;
public const STATE_STOPPED = 3;
public const STATE_EXPORTED = 4;
public const STATE_NOT_EXPORTED = 5;
/**
* Overwritten for different default order
@@ -44,6 +46,10 @@ class TimesheetQuery extends ActivityQuery
* @var int
*/
protected $state = self::STATE_ALL;
/**
* @var int
*/
protected $exported = self::STATE_ALL;
/**
* @var DateRange
*/
@@ -84,10 +90,10 @@ class TimesheetQuery extends ActivityQuery
}
/**
* @param Activity $activity
* @param Activity|int $activity
* @return TimesheetQuery
*/
public function setActivity(Activity $activity = null)
public function setActivity($activity = null)
{
$this->activity = $activity;
@@ -108,7 +114,7 @@ class TimesheetQuery extends ActivityQuery
*/
public function setState($state)
{
if (!is_int($state) && $state != (int) $state) {
if (!is_int($state) && $state !== (int) $state) {
return $this;
}
@@ -120,6 +126,32 @@ class TimesheetQuery extends ActivityQuery
return $this;
}
/**
* @return int
*/
public function getExported()
{
return $this->exported;
}
/**
* @param int $exported
* @return TimesheetQuery
*/
public function setExported($exported)
{
if (!is_int($exported) && $exported !== (int) $exported) {
return $this;
}
$exported = (int) $exported;
if (in_array($exported, [self::STATE_ALL, self::STATE_EXPORTED, self::STATE_NOT_EXPORTED], true)) {
$this->exported = $exported;
}
return $this;
}
/**
* @return \DateTime
*/

View File

@@ -338,11 +338,18 @@ class TimesheetRepository extends AbstractRepository
->setParameter('end', $query->getEnd());
}
if ($query->getExported() === TimesheetQuery::STATE_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true);
} elseif ($query->getExported() === TimesheetQuery::STATE_NOT_EXPORTED) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false);
}
if (null !== $query->getActivity()) {
$qb->andWhere('t.activity = :activity')
->setParameter('activity', $query->getActivity());
}
// TODO if activity is an int, this will fail
if (null === $query->getActivity() || null === $query->getActivity()->getProject()) {
if (null !== $query->getProject()) {
$qb->andWhere('t.project = :project')

View File

@@ -39,6 +39,7 @@ class DateExtensions extends \Twig_Extension
return [
new TwigFilter('month_name', [$this, 'monthName']),
new TwigFilter('date_short', [$this, 'dateShort']),
new TwigFilter('date_time', [$this, 'dateTime']),
];
}
@@ -53,6 +54,17 @@ class DateExtensions extends \Twig_Extension
return date_format($date, $format);
}
/**
* @param DateTime $date
* @return string
*/
public function dateTime(DateTime $date)
{
$format = $this->localeSettings->getDateTimeFormat();
return date_format($date, $format);
}
/**
* @param \DateTime $date
* @return string

View File

@@ -86,6 +86,13 @@ class Extensions extends \Twig_Extension
'user' => 'fas fa-user',
'visibility' => 'far fa-eye',
'settings' => 'fas fa-wrench',
'export' => 'fas fa-database',
'pdf' => 'fas fa-file-pdf',
'csv' => 'fas fa-table',
'ods' => 'fas fa-table',
'xlsx' => 'fas fa-file-excel',
'on' => 'fas fa-toggle-on',
'off' => 'fas fa-toggle-off',
];
/**

View File

@@ -111,6 +111,17 @@ class LocaleSettings
return $this->getConfigByLocaleAndKey('date', $locale);
}
/**
* Returns the locale specific datetime format, which should be used in combination with the twig filter "|date".
*
* @param null|string $locale
* @return string
*/
public function getDateTimeFormat(?string $locale = null): string
{
return $this->getConfigByLocaleAndKey('date_time', $locale);
}
/**
* Returns the format used in the "|duration" twig filter to display a Timesheet duration.
*
@@ -143,5 +154,4 @@ class LocaleSettings
return $this->settings[$locale][$key];
}
}

View File

@@ -26,6 +26,7 @@ class TimesheetVoter extends AbstractVoter
public const EXPORT = 'export';
public const VIEW_RATE = 'view_rate';
public const EDIT_RATE = 'edit_rate';
public const EDIT_EXPORT = 'edit_export';
/**
* support rules based on the given $subject (here: Timesheet)
@@ -38,6 +39,7 @@ class TimesheetVoter extends AbstractVoter
self::EXPORT,
self::VIEW_RATE,
self::EDIT_RATE,
self::EDIT_EXPORT,
];
/**
@@ -92,6 +94,7 @@ class TimesheetVoter extends AbstractVoter
case self::EDIT:
case self::DELETE:
case self::EXPORT:
case self::EDIT_EXPORT:
$permission .= $attribute;
break;