refactored invoice and export screens (#1046)

This commit is contained in:
Kevin Papst
2019-08-22 18:56:21 +02:00
committed by GitHub
parent 405ea0076b
commit 46e5650882
55 changed files with 930 additions and 906 deletions

View File

@@ -17,6 +17,7 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
@@ -25,7 +26,7 @@ use Symfony\Component\Routing\Annotation\Route;
* Controller used to export timesheet data.
*
* @Route(path="/export")
* @Security("is_granted('view_export')")
* @Security("is_granted('create_export')")
*/
class ExportController extends AbstractController
{
@@ -54,61 +55,45 @@ class ExportController extends AbstractController
$this->dateFactory = $dateTime;
}
/**
* @return ExportQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = $this->dateFactory->createDateTime('first day of this month 00:00:00');
$end = $this->dateFactory->createDateTime('last day of this month 23:59:59');
$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);
$query->setCurrentUser($this->getUser());
return $query;
}
/**
* @Route(path="/", name="export", methods={"GET"})
* @Security("is_granted('view_export')")
*
* @param Request $request
* @return Response
* @throws \Exception
*/
public function indexAction(Request $request)
public function indexAction(Request $request): Response
{
$query = $this->getDefaultQuery();
$showPreview = false;
$maxItemsPreview = 500;
$entries = [];
$form = $this->getToolbarForm($query, 'GET');
$form->setData($query);
$form->submit($request->query->all(), false);
$entries = $this->getEntries($query);
if ($form->isValid()) {
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$query->setPageSize($maxItemsPreview);
$entries = $this->getEntries($query);
}
}
return $this->render('export/index.html.twig', [
'query' => $query,
'entries' => $entries,
'form' => $form->createView(),
'renderer' => $this->export->getRenderer(),
'preview_max' => $maxItemsPreview,
'preview_show' => $showPreview,
]);
}
/**
* @Route(path="/data", name="export_data", methods={"POST"})
* @Security("is_granted('create_export')")
*
* @param Request $request
* @return Response
* @throws \Exception
*/
public function export(Request $request)
public function export(Request $request): Response
{
$query = $this->getDefaultQuery();
@@ -127,8 +112,29 @@ class ExportController extends AbstractController
}
$entries = $this->getEntries($query);
$response = $renderer->render($entries, $query);
return $renderer->render($entries, $query);
if ($query->isMarkAsExported()) {
$this->timesheetRepository->setExported($entries);
}
return $response;
}
protected function getDefaultQuery(): ExportQuery
{
$begin = $this->dateFactory->createDateTime('first day of this month 00:00:00');
$end = $this->dateFactory->createDateTime('last day of this month 23:59:59');
$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);
$query->setCurrentUser($this->getUser());
return $query;
}
/**

View File

@@ -22,14 +22,16 @@ use App\Repository\TimesheetRepository;
use App\Timesheet\UserDateTimeFactory;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
/**
* Controller used to manage invoices.
* Controller used to create invoices and manage invoice templates.
*
* @Route(path="/invoice")
* @Security("is_granted('view_invoice') or is_granted('view_invoice_template')")
* @Security("is_granted('view_invoice')")
*/
class InvoiceController extends AbstractController
{
@@ -58,26 +60,7 @@ class InvoiceController extends AbstractController
}
/**
* @return InvoiceQuery
* @throws \Exception
*/
protected function getDefaultQuery()
{
$begin = $this->dateTimeFactory->createDateTime('first day of this month');
$end = $this->dateTimeFactory->createDateTime('last day of this month');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setState(InvoiceQuery::STATE_STOPPED);
$query->setCurrentUser($this->getUser());
return $query;
}
/**
* @Route(path="/", name="invoice", methods={"GET"})
* @Route(path="/", name="invoice", methods={"GET", "POST"})
* @Security("is_granted('view_invoice')")
*
* @param Request $request
@@ -87,9 +70,14 @@ class InvoiceController extends AbstractController
public function indexAction(Request $request, TimesheetRepository $repository)
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
if ($this->isGranted('manage_invoice_template')) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$this->flashWarning('invoice.first_template');
}
$showPreview = false;
$maxItemsPreview = 500;
$entries = [];
$query = $this->getDefaultQuery();
@@ -97,8 +85,22 @@ class InvoiceController extends AbstractController
$form->setData($query);
$form->submit($request->query->all(), false);
if ($form->isValid()) {
$entries = $this->getEntries($query, $repository);
if ($this->isGranted('create_invoice')) {
if ($form->isValid()) {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query, $repository);
}
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$query->setPageSize($maxItemsPreview);
$entries = $this->getEntries($query, $repository);
}
}
}
$model = $this->prepareModel($query, $entries);
@@ -106,34 +108,29 @@ class InvoiceController extends AbstractController
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
'preview_max' => $maxItemsPreview,
'preview_show' => $showPreview,
]);
}
/**
* @Route(path="/print", name="invoice_print", methods={"POST"})
* @Security("is_granted('create_invoice')")
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function printAction(Request $request, TimesheetRepository $repository)
protected function getDefaultQuery(): InvoiceQuery
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$begin = $this->dateTimeFactory->createDateTime('first day of this month');
$end = $this->dateTimeFactory->createDateTime('last day of this month');
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, 'POST');
$query = new InvoiceQuery();
$query->setOrder(InvoiceQuery::ORDER_ASC);
$query->setBegin($begin);
$query->setEnd($end);
$query->setExported(InvoiceQuery::STATE_NOT_EXPORTED);
$query->setState(InvoiceQuery::STATE_STOPPED);
$query->setCurrentUser($this->getUser());
$form->handleRequest($request);
return $query;
}
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->redirectToRoute('invoice');
}
/** @var InvoiceQuery $query */
$query = $form->getData();
protected function renderInvoice(InvoiceQuery $query, TimesheetRepository $repository)
{
$entries = $this->getEntries($query, $repository);
$model = $this->prepareModel($query, $entries);
@@ -144,23 +141,28 @@ class InvoiceController extends AbstractController
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
return $renderer->render($document, $model);
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$repository->setExported($entries);
}
return $response;
}
}
$this->flashError('Cannot render invoice: ' . $model->getTemplate()->getRenderer() . ' (' . $document->getName() . ')');
$this->flashError(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
return $this->render('invoice/index.html.twig', [
'model' => $model,
'form' => $form->createView(),
]);
return $this->redirectToRoute('invoice');
}
/**
* @param InvoiceQuery $query
* @param TimesheetRepository $repository
* @return Timesheet[]
*/
protected function getEntries(InvoiceQuery $query, TimesheetRepository $repository)
protected function getEntries(InvoiceQuery $query, TimesheetRepository $repository): iterable
{
// customer needs to be defined, as we need the currency for the invoice
if (null === $query->getCustomer()) {
@@ -181,11 +183,11 @@ class InvoiceController extends AbstractController
/**
* @param InvoiceQuery $query
* @param array $entries
* @param Timesheet[] $entries
* @return InvoiceModel
* @throws \Exception
*/
protected function prepareModel(InvoiceQuery $query, array $entries)
protected function prepareModel(InvoiceQuery $query, array $entries): InvoiceModel
{
$model = new InvoiceModel();
$model
@@ -214,33 +216,23 @@ class InvoiceController extends AbstractController
}
/**
* @Route(path="/template", defaults={"page": 1}, name="admin_invoice_template", methods={"GET", "POST"})
* @Route(path="/template/page/{page}", requirements={"page": "[1-9]\d*"}, name="admin_invoice_template_paginated", methods={"GET", "POST"})
* @Security("is_granted('view_invoice_template')")
*
* @param int $page
* @return \Symfony\Component\HttpFoundation\Response
* @Route(path="/template", name="admin_invoice_template", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
*/
public function listTemplateAction($page)
public function listTemplateAction(): Response
{
$templates = $this->invoiceRepository->findByQuery(new BaseQuery());
return $this->render('invoice/templates.html.twig', [
'entries' => $templates,
'page' => $page,
]);
}
/**
* @Route(path="/template/{id}/edit", name="admin_invoice_template_edit", methods={"GET", "POST"})
* @Security("is_granted('edit', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
* @Security("is_granted('manage_invoice_template')")
*/
public function editTemplateAction(InvoiceTemplate $template, Request $request)
public function editTemplateAction(InvoiceTemplate $template, Request $request): Response
{
return $this->renderTemplateForm($template, $request);
}
@@ -248,14 +240,9 @@ class InvoiceController extends AbstractController
/**
* @Route(path="/template/create", name="admin_invoice_template_create", methods={"GET", "POST"})
* @Route(path="/template/create/{id}", name="admin_invoice_template_copy", methods={"GET", "POST"})
* @Security("is_granted('create_invoice_template')")
*
* @param Request $request
* @param InvoiceTemplate|null $copyFrom
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
* @Security("is_granted('manage_invoice_template')")
*/
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom)
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
{
if (!$this->invoiceRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
@@ -281,16 +268,10 @@ class InvoiceController extends AbstractController
}
/**
* The route to delete an existing template.
*
* @Route(path="/template/{id}/delete", name="admin_invoice_template_delete", methods={"GET", "POST"})
* @Security("is_granted('delete', template)")
*
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
* @Security("is_granted('manage_invoice_template')")
*/
public function deleteTemplate(InvoiceTemplate $template, Request $request)
public function deleteTemplate(InvoiceTemplate $template, Request $request): Response
{
try {
$this->invoiceRepository->removeTemplate($template);
@@ -299,15 +280,10 @@ class InvoiceController extends AbstractController
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
}
return $this->redirectToRoute('admin_invoice_template_paginated', ['page' => $request->get('page')]);
return $this->redirectToRoute('admin_invoice_template');
}
/**
* @param InvoiceTemplate $template
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
protected function renderTemplateForm(InvoiceTemplate $template, Request $request)
protected function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$editForm = $this->createEditForm($template);
@@ -341,11 +317,7 @@ class InvoiceController extends AbstractController
]);
}
/**
* @param InvoiceTemplate $template
* @return \Symfony\Component\Form\FormInterface
*/
private function createEditForm(InvoiceTemplate $template)
private function createEditForm(InvoiceTemplate $template): FormInterface
{
if ($template->getId() === null) {
$url = $this->generateUrl('admin_invoice_template_create');

View File

@@ -84,7 +84,7 @@ final class MenuSubscriber implements EventSubscriberInterface
);
}
if ($auth->isGranted('view_export')) {
if ($auth->isGranted('create_export')) {
$menu->addItem(
new MenuItemModel('export', 'menu.export', 'export', [], $this->getIcon('export'))
);

View File

@@ -12,9 +12,12 @@ namespace App\Export\Renderer;
use App\Entity\Timesheet;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
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\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
@@ -22,72 +25,88 @@ use Symfony\Contracts\Translation\TranslatorInterface;
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 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
) {
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
protected function setFormattedDateTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
return $this->dateExtension->dateShort($date) . ' ' . $this->dateExtension->time($date);
}
if (null === $date) {
$sheet->setCellValueByColumnAndRow($column, $row, '');
/**
* @param int $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;
}
return $timesheet->getUser()->getUsername();
$sheet->setCellValueByColumnAndRow($column, $row, Date::PHPToExcel($date));
$sheet->getStyleByColumnAndRow($column, $row)->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
}
/**
* @param int $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
protected function setFormattedTime(Worksheet $sheet, $column, $row, ?DateTime $date)
{
return $this->extension->duration($seconds);
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)
{
$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)
);
}
/**
@@ -113,8 +132,11 @@ abstract class AbstractSpreadsheetRenderer
$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'));
@@ -122,45 +144,67 @@ abstract class AbstractSpreadsheetRenderer
$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 ($publicMetaFields as $metaFieldName) {
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans($metaFieldName));
}
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.hourlyRate'));
$sheet->setCellValueByColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow, $this->translator->trans('label.fixedRate'));
$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;
$durationColumn = null;
$rateColumn = null;
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, implode(',', $timesheet->getTagsAsArray()));
$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 ($publicMetaFields as $metaFieldName) {
$metaField = $timesheet->getMetaField($metaFieldName);
$metaFieldValue = '';
@@ -169,28 +213,40 @@ abstract class AbstractSpreadsheetRenderer
}
$sheet->setCellValueByColumnAndRow($entryHeaderColumn++, $entryHeaderRow, $metaFieldValue);
}
$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++;
}
$cellDurationTotal = $recordsHeaderColumn - 1;
$cellRateTotal = $recordsHeaderColumn;
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);
}
$sheet->setCellValueByColumnAndRow($cellDurationTotal, $entryHeaderRow, $this->getFormattedDuration($durationTotal));
$sheet->setCellValueByColumnAndRow($cellRateTotal, $entryHeaderRow, $this->getFormattedMoney($rateTotal, $currency));
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellDurationTotal, $entryHeaderRow)->getStyle()->getFont()->setBold(true);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
$sheet->getCellByColumnAndRow($cellRateTotal, $entryHeaderRow)->getStyle()->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

View File

@@ -12,6 +12,7 @@ namespace App\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
@@ -72,4 +73,9 @@ final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererI
{
return 'csv';
}
protected function setDuration(Worksheet $sheet, $column, $row, $duration)
{
$sheet->setCellValueByColumnAndRow($column, $row, sprintf('=%s', $duration));
}
}

View File

@@ -1,75 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Export\Renderer;
use App\Export\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
final 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 string
* @throws \Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-export-ods');
if (false === $filename) {
throw new \Exception('Could not open temporary file');
}
$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

@@ -24,7 +24,9 @@ use App\Repository\ProjectRepository;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
@@ -48,9 +50,6 @@ abstract class AbstractToolbarForm extends AbstractType
return '';
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserChoice(FormBuilderInterface $builder)
{
$builder->add('user', UserType::class, [
@@ -59,17 +58,26 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
protected function addCustomerChoice(FormBuilderInterface $builder)
protected function addUsersChoice(FormBuilderInterface $builder)
{
$builder->add('users', UserType::class, [
'label' => 'label.user',
'multiple' => true,
'required' => false,
]);
}
protected function addCustomerChoice(FormBuilderInterface $builder, bool $required = false)
{
// just a fake field for having this field at the right position in the frontend
$builder->add('customer', HiddenType::class);
$builder->addEventListener(
FormEvents::PRE_SUBMIT,
function (FormEvent $event) use ($builder) {
function (FormEvent $event) use ($builder, $required) {
$data = $event->getData();
$event->getForm()->add('customer', CustomerType::class, [
'required' => false,
'required' => $required,
'project_enabled' => true,
'query_builder' => function (CustomerRepository $repo) use ($builder, $data) {
$query = new CustomerFormTypeQuery();
@@ -85,10 +93,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
* @param string $label
*/
protected function addVisibilityChoice(FormBuilderInterface $builder, string $label = 'label.visible')
{
$builder->add('visibility', VisibilityType::class, [
@@ -98,9 +102,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addPageSizeChoice(FormBuilderInterface $builder)
{
$builder->add('pageSize', PageSizeType::class, [
@@ -108,9 +109,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addUserRoleChoice(FormBuilderInterface $builder)
{
$builder->add('role', UserRoleType::class, [
@@ -118,9 +116,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addDateRangeChoice(FormBuilderInterface $builder, $allowEmpty = true)
{
$builder->add('daterange', DateRangeType::class, [
@@ -129,9 +124,6 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addProjectChoice(FormBuilderInterface $builder)
{
// just a fake field for having this field at the right position in the frontend
@@ -162,9 +154,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addActivityChoice(FormBuilderInterface $builder)
{
// just a fake field for having this field at the right position in the frontend
@@ -193,9 +182,6 @@ abstract class AbstractToolbarForm extends AbstractType
);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addHiddenPagination(FormBuilderInterface $builder)
{
$builder->add('page', HiddenType::class, [
@@ -203,13 +189,38 @@ abstract class AbstractToolbarForm extends AbstractType
]);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addTagInputField(FormBuilderInterface $builder)
{
$builder->add('tags', TagsInputType::class, [
'required' => false
]);
}
protected function addTimesheetStateChoice(FormBuilderInterface $builder)
{
$builder->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
]);
}
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
],
]);
}
}

View File

@@ -10,9 +10,9 @@
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\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -27,13 +27,21 @@ class ExportToolbarForm extends AbstractToolbarForm
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addExportStateChoice($builder);
$this->addUserChoice($builder);
$this->addTimesheetStateChoice($builder);
$this->addUsersChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addExportType($builder);
$this->addTagInputField($builder);
$builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported',
'required' => false,
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
}
/**
@@ -44,23 +52,6 @@ class ExportToolbarForm extends AbstractToolbarForm
$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}
*/

View File

@@ -11,6 +11,8 @@ namespace App\Form\Toolbar;
use App\Form\Type\InvoiceTemplateType;
use App\Repository\Query\InvoiceQuery;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -25,18 +27,30 @@ class InvoiceToolbarForm extends AbstractToolbarForm
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->addTemplateChoice($builder);
$this->addUserChoice($builder);
$this->addUsersChoice($builder);
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder);
$this->addCustomerChoice($builder, true);
$this->addProjectChoice($builder);
$this->addActivityChoice($builder);
$this->addTagInputField($builder);
$this->addExportStateChoice($builder);
$builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported',
'required' => false,
]);
$builder->add('create', SubmitType::class, [
'label' => 'button.print',
'attr' => ['formtarget' => 'invoice'],
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
}
protected function addTemplateChoice(FormBuilderInterface $builder)
{
$builder->add('template', InvoiceTemplateType::class, [
'required' => false,
'required' => true,
'placeholder' => null,
]);
}

View File

@@ -10,7 +10,6 @@
namespace App\Form\Toolbar;
use App\Repository\Query\TimesheetQuery;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -37,23 +36,6 @@ class TimesheetToolbarForm extends AbstractToolbarForm
$this->addHiddenPagination($builder);
}
/**
* @param FormBuilderInterface $builder
*/
protected function addTimesheetStateChoice(FormBuilderInterface $builder)
{
$builder->add('state', ChoiceType::class, [
'label' => 'label.entryState',
'required' => false,
'placeholder' => null,
'choices' => [
'entryState.all' => TimesheetQuery::STATE_ALL,
'entryState.running' => TimesheetQuery::STATE_RUNNING,
'entryState.stopped' => TimesheetQuery::STATE_STOPPED
],
]);
}
/**
* {@inheritdoc}
*/

View File

@@ -123,6 +123,8 @@ trait RendererTrait
'activity.id' => $activity->getId(),
'activity.name' => $activity->getName(),
'activity.comment' => $activity->getComment(),
'activity.fixed_rate' => $activity->getFixedRate(),
'activity.hourly_rate' => $activity->getHourlyRate(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
@@ -138,6 +140,8 @@ trait RendererTrait
'project.name' => $project->getName(),
'project.comment' => $project->getComment(),
'project.order_number' => $project->getOrderNumber(),
'project.fixed_rate' => $project->getFixedRate(),
'project.hourly_rate' => $project->getHourlyRate(),
]);
foreach ($project->getVisibleMetaFields() as $metaField) {
@@ -158,6 +162,8 @@ trait RendererTrait
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(),
'customer.fixed_rate' => $customer->getFixedRate(),
'customer.hourly_rate' => $customer->getHourlyRate(),
]);
foreach ($customer->getVisibleMetaFields() as $metaField) {

View File

@@ -50,7 +50,7 @@ class BaseQuery
/**
* @var User
*/
private $user;
private $currentUser;
/**
* @var Team[]
*/
@@ -73,7 +73,7 @@ class BaseQuery
public function getCurrentUser(): ?User
{
return $this->user;
return $this->currentUser;
}
/**
@@ -82,7 +82,7 @@ class BaseQuery
*/
public function setCurrentUser(User $user)
{
$this->user = $user;
$this->currentUser = $user;
return $this;
}

View File

@@ -15,23 +15,32 @@ class ExportQuery extends TimesheetQuery
* @var string
*/
private $type;
/**
* @return string
* @var bool
*/
private $markAsExported = false;
public function getType(): ?string
{
return $this->type;
}
/**
* @param string $type
* @return ExportQuery
*/
public function setType(string $type)
public function setType(string $type): ExportQuery
{
$this->type = $type;
return $this;
}
public function isMarkAsExported(): bool
{
return $this->markAsExported;
}
public function setMarkAsExported(bool $markAsExported): ExportQuery
{
$this->markAsExported = $markAsExported;
return $this;
}
}

View File

@@ -11,32 +11,38 @@ namespace App\Repository\Query;
use App\Entity\InvoiceTemplate;
/**
* Can be used for invoice queries.
*/
class InvoiceQuery extends TimesheetQuery
{
/**
* @var InvoiceTemplate
*/
private $template;
/**
* @return InvoiceTemplate
* @var bool
*/
public function getTemplate()
private $markAsExported = false;
public function getTemplate(): ?InvoiceTemplate
{
return $this->template;
}
/**
* @param InvoiceTemplate $template
* @return InvoiceQuery
*/
public function setTemplate($template)
public function setTemplate(InvoiceTemplate $template): InvoiceQuery
{
$this->template = $template;
return $this;
}
public function isMarkAsExported(): bool
{
return $this->markAsExported;
}
public function setMarkAsExported(bool $markAsExported): InvoiceQuery
{
$this->markAsExported = $markAsExported;
return $this;
}
}

View File

@@ -49,6 +49,10 @@ class TimesheetQuery extends ActivityQuery
* @var iterable
*/
protected $tags = [];
/**
* @var User[]
*/
private $users = [];
public function __construct()
{
@@ -58,6 +62,30 @@ class TimesheetQuery extends ActivityQuery
$this->dateRange = new DateRange();
}
public function addUser(User $user): self
{
$this->users[$user->getId()] = $user;
return $this;
}
public function removeUser(User $user): self
{
if (isset($this->users[$user->getId()])) {
unset($this->users[$user->getId()]);
}
return $this;
}
/**
* @return User[]
*/
public function getUsers(): array
{
return array_values($this->users);
}
/**
* @return User|null
*/

View File

@@ -538,7 +538,9 @@ class TimesheetRepository extends EntityRepository
$user[] = $query->getUser();
}
if (null === $query->getUser() && null !== $query->getCurrentUser()) {
$user = array_merge($user, $query->getUsers());
if (empty($user) && null !== $query->getCurrentUser()) {
$currentUser = $query->getCurrentUser();
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) {
@@ -678,4 +680,25 @@ class TimesheetRepository extends EntityRepository
return $results;
}
/**
* @param Timesheet[] $timesheets
*/
public function setExported(array $timesheets)
{
$em = $this->getEntityManager();
$em->beginTransaction();
$qb = $em->createQueryBuilder();
$qb
->update(Timesheet::class, 't')
->set('t.exported', ':exported')
->where($qb->expr()->in('t.id', ':ids'))
->setParameter('exported', true, \PDO::PARAM_BOOL)
->setParameter('ids', $timesheets)
->getQuery()
->execute();
$em->commit();
}
}

View File

@@ -1,68 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
/**
* A voter to check permissions on InvoiceTemplateVote.
*/
class InvoiceTemplateVoter extends AbstractVoter
{
public const VIEW = 'view';
public const EDIT = 'edit';
public const DELETE = 'delete';
/**
* support rules based on the given $subject (here: InvoiceTemplate)
*/
public const ALLOWED_ATTRIBUTES = [
self::VIEW,
self::EDIT,
self::DELETE
];
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @return bool
*/
protected function supports($attribute, $subject)
{
if (!($subject instanceof InvoiceTemplate)) {
return false;
}
if (!in_array($attribute, self::ALLOWED_ATTRIBUTES)) {
return false;
}
return true;
}
/**
* @param string $attribute
* @param InvoiceTemplate $subject
* @param TokenInterface $token
* @return bool
*/
protected function voteOnAttribute($attribute, $subject, TokenInterface $token)
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->hasRolePermission($user, $attribute . '_invoice_template');
}
}