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

@@ -10,13 +10,17 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.2](https://github.com/kevinpapst/kimai2/releases/tag/1.2)
### BC breaks
### Possible BC breaks
- Deleted timezone conversion command. If you are still using 0.7 or below, you need to upgrade to 1.1 before upgrading to this version.
- Minimum password length raised from 5 to 8 character (applies only for password changes and new users)
- Maximum customer name length lowered to 150 character
- Maximum project name length lowered to 150 character
- Maximum activity name length lowered to 150 character
- Added new permission: `manage_invoice_template`
- Removed permissions: `view_invoice_template`, `create_invoice_template`, `edit_invoice_template`, `delete_invoice_template`
- Removed permission: `view_invoice` (using `create_export` only)
- Export renderer need to check usage of `Timesheet::getEnd()` as running entries can now be exported as well
## [1.1](https://github.com/kevinpapst/kimai2/releases/tag/1.1)

27
composer.lock generated
View File

@@ -3902,16 +3902,16 @@
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.8.2",
"version": "1.9.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "0c1346a1956347590b7db09533966307d20cb7cc"
"reference": "8dea03eaf60a349b6097e4bcad11f894668280df"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/0c1346a1956347590b7db09533966307d20cb7cc",
"reference": "0c1346a1956347590b7db09533966307d20cb7cc",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/8dea03eaf60a349b6097e4bcad11f894668280df",
"reference": "8dea03eaf60a349b6097e4bcad11f894668280df",
"shasum": ""
},
"require": {
@@ -3930,17 +3930,16 @@
"ext-zlib": "*",
"markbaker/complex": "^1.4",
"markbaker/matrix": "^1.1",
"php": "^5.6|^7.0",
"php": "^7.1",
"psr/simple-cache": "^1.0"
},
"require-dev": {
"doctrine/instantiator": "^1.0.0",
"dompdf/dompdf": "^0.8.0",
"friendsofphp/php-cs-fixer": "@stable",
"jpgraph/jpgraph": "^4.0",
"mpdf/mpdf": "^7.0.0",
"phpcompatibility/php-compatibility": "^8.0",
"phpunit/phpunit": "^5.7",
"phpunit/phpunit": "^7.5",
"squizlabs/php_codesniffer": "^3.3",
"tecnickcom/tcpdf": "^6.2"
},
@@ -3961,12 +3960,6 @@
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
@@ -3978,6 +3971,12 @@
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -3992,7 +3991,7 @@
"xls",
"xlsx"
],
"time": "2019-07-08T21:21:25+00:00"
"time": "2019-08-17T22:24:35+00:00"
},
{
"name": "phpoffice/phpword",

View File

@@ -90,7 +90,7 @@ kimai:
CUSTOMERS: ['view_customer','create_customer','edit_customer','budget_customer','delete_customer','permissions_customer']
CUSTOMERS_TEAM: ['view_customer','edit_teamlead_customer','budget_teamlead_customer']
INVOICE: ['view_invoice','create_invoice']
INVOICE_TEMPLATE: ['view_invoice_template','create_invoice_template','edit_invoice_template','delete_invoice_template']
INVOICE_TEMPLATE: ['manage_invoice_template']
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
@@ -99,16 +99,16 @@ kimai:
USER: ['view_user','create_user','delete_user','role_permissions']
RATE: ['view_rate_own_timesheet','edit_rate_own_timesheet']
RATE_OTHER: ['view_rate_other_timesheet','edit_rate_other_timesheet']
EXPORT: ['view_export','create_export','edit_export_own_timesheet','edit_export_other_timesheet']
EXPORT: ['create_export','edit_export_own_timesheet','edit_export_other_timesheet']
TEAMS: ['view_team','create_team','edit_team','delete_team']
# some single default definitions for roles
SINGLE_USER: []
SINGLE_TEAMLEAD: ['!delete_invoice_template','view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
SINGLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile']
SINGLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile']
SINGLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','delete_own_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile']
# link above sets to one set for each user role
ROLE_USER: ['@TIMESHEET','@PROFILE']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@INVOICE_TEMPLATE','@SINGLE_TEAMLEAD']
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAM','@PROJECTS_TEAM','@CUSTOMERS_TEAM','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])

View File

@@ -3,6 +3,7 @@ includes:
- vendor/phpstan/phpstan-doctrine/extension.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
autoload_directories:
- %rootDir%/../../../src/Migrations
# symfony:

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');
}
}

View File

@@ -85,7 +85,7 @@
</a>
</li>
{% endif %}
{% if is_granted('view_export') %}
{% if is_granted('create_export') %}
<li class="visible-xs-inline-block">
<a href="{{ path('export') }}" class="ddt-large">
<i class="{{ 'export'|icon }} fa-2x"></i>

View File

@@ -1,3 +1,6 @@
{% if form_theme is defined %}
{% form_theme form form_theme %}
{% endif %}
<div class="box box-primary">
{{ form_start(form) }}
<div class="box-header with-border">

View File

@@ -0,0 +1,13 @@
{% macro export(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if view == 'preview' %}
{% set actions = {'off': {'id':'export-toggle-button'}} %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'export.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.export', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -2,7 +2,7 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/actions.html.twig" as actions %}
{% import "export/actions.html.twig" as actions %}
{% set columns = {
'date': 'alwaysVisible',
@@ -21,89 +21,96 @@
{% block page_title %}{{ 'export.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'export.subtitle'|trans }}{% endblock %}
{% block page_actions %}{{ actions.export('index') }}{% endblock %}
{% block main_before %}
{{ toolbar.toolbar(form, 'collapseExport', true) }}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main_after %}
<div class="clearfix"></div>
{% endblock %}
{% block page_actions %}{{ actions.export((preview_show ? 'preview' : 'index')) }}{% endblock %}
{% block main %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set currency = entry.project.customer.currency %}
{% set duration = entry.duration|duration %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.project.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">{{ widgets.label_project(entry.project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{{ entry.description }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-nowrap">
{{ rate|money(currency) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-nowrap">
{{ duration }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }} text-nowrap">
{{ entry.rate|money(currency) }}
</td>
<td>
{% if is_granted('edit_export', entry) %}
{% if entry.exported %}
<button type="button" class="btn btn-default exportBtn active" data-toggle="button" aria-pressed="true" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.exported'|trans }}
</button>
{% else %}
<button type="button" class="btn btn-default exportBtn" data-toggle="button" aria-pressed="false" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.not_exported'|trans }}
</button>
{% endif %}
{% else %}
{% if entry.exported %}
{{ 'entryState.exported'|trans }}
{% else %}
{{ 'entryState.not_exported'|trans }}
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% if is_granted('create_export') %}
<div class="row no-print">
<div class="col-xs-12">
<div class="btn-group pull-right" id="export-buttons" role="group">
{% for button in renderer %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
<i class="{{ button.icon|icon }}"></i> {{ ('button.' ~ button.title)|trans }}
</button>
{% endfor %}
</div>
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'export.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %}
{% block box_body %}
{{ form_row(form.daterange) }}
{{ form_row(form.customer) }}
{{ form_row(form.project) }}
{{ form_row(form.activity) }}
{{ form_row(form.users) }}
{{ form_row(form.tags) }}
{{ form_row(form.exported) }}
{{ form_row(form.state) }}
{{ form_row(form.markAsExported) }}
{% endblock %}
{% block box_footer%}
<div class="btn-group" id="export-buttons" role="group">
{% for button in renderer %}
<button type="button" id="export-{{ button.id }}-button" class="btn btn-success startExportBtn" data-type="{{ button.id }}">
<i class="{{ button.icon|icon }}"></i> {{ ('button.' ~ button.title)|trans }}
</button>
{% endfor %}
</div>
</div>
{{ form_widget(form.preview, {'attr': {'data-toggle': 'tooltip', 'title': 'limited_entries'|trans({'%max%': preview_max})}}) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %}
{% if preview_show %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set currency = entry.project.customer.currency %}
{% set duration = entry.duration|duration %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.project.customer) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">{{ widgets.label_project(entry.project) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{{ entry.description }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-nowrap">
{{ rate|money(currency) }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-nowrap">
{{ duration }}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }} text-nowrap">
{{ entry.rate|money(currency) }}
</td>
<td>
{% if is_granted('edit_export', entry) %}
{% if entry.exported %}
<button type="button" class="btn btn-default exportBtn active" data-toggle="button" aria-pressed="true" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.exported'|trans }}
</button>
{% else %}
<button type="button" class="btn btn-default exportBtn" data-toggle="button" aria-pressed="false" autocomplete="off"
data-exported-text="{{ 'entryState.exported'|trans }}" data-clean-text="{{ 'entryState.not_exported'|trans }}" data-timesheet="{{ entry.id }}">
{{ 'entryState.not_exported'|trans }}
</button>
{% endif %}
{% else %}
{% if entry.exported %}
{{ 'entryState.exported'|trans }}
{% else %}
{{ 'entryState.not_exported'|trans }}
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endif %}

View File

@@ -260,8 +260,10 @@
<tr>
<td class="column-date text-nowrap" {% if not columns.date %}style="display: none"{% endif %}>
{{ entry.begin|date_time }}
<br>
{{ entry.end|date_time }}
{% if entry.end %}
<br>
{{ entry.end|date_time }}
{% endif %}
</td>
<td class="column-username" {% if not columns.username %}style="display: none"{% endif %}>
{{ widgets.username(entry.user) }}

View File

@@ -144,8 +144,10 @@ mpdf-->
<tr class="{{ cycle(['odd', 'even'], loop.index0) }}">
<td>
{{ entry.begin|date_time }}
<br>
{{ entry.end|date_time }}
{% if entry.end %}
<br>
{{ entry.end|date_time }}
{% endif %}
</td>
<td>
{{ entry.project.customer.name }} - {{ entry.project.name }} - {{ entry.activity.name }}

View File

@@ -0,0 +1,47 @@
{% macro invoices(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'list': path('admin_invoice_template')}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoices', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro invoice_templates(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('view_invoice') %}
{% set actions = actions|merge({'back': path('invoice')}) %}
{% endif %}
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index'}) %}
{{ widgets.page_actions(actions) }}
{% endmacro %}
{% macro invoice_template(template, view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'edit': {'url': path('admin_invoice_template_edit', {'id' : template.id}), 'class': 'modal-ajax-form'}}) %}
{% set actions = actions|merge({'copy': path('admin_invoice_template_copy', {'id' : template.id})}) %}
{% set actions = actions|merge({'trash': path('admin_invoice_template_delete', {'id' : template.id})}) %}
{% endif %}
{% set event = trigger('actions.invoice_template', {'actions': actions, 'view': view, 'template': template}) %}
{{ widgets.table_actions(event.payload.actions) }}
{% endmacro %}

View File

@@ -2,11 +2,11 @@
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/actions.html.twig" as actions %}
{% import "invoice/actions.html.twig" as actions %}
{% set columns = {
'date': 'alwaysVisible',
'user': 'hidden-sm',
'user': 'hidden-xs hidden-sm',
'description': 'hidden-xs hidden-sm',
'unit_price': 'hidden-xs text-center',
'amount': 'text-center',
@@ -19,63 +19,66 @@
{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %}
{% block page_actions %}{{ actions.invoices('index') }}{% endblock %}
{% block main_before %}
{{ toolbar.toolbar(form, 'collapseInvoice', true) }}
{{ tables.data_table_column_modal(tableName, columns) }}
{% endblock %}
{% block main_after %}
<div class="clearfix"></div>
{% endblock %}
{% block main %}
{% set entries = {} %}
{% if model.calculator is not empty and model.calculator.entries is not empty %}
{% set entries = model.calculator.entries %}
{% if is_granted('create_invoice') %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'invoice.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %}
{% block box_body %}
{{ form_row(form.daterange) }}
{{ form_row(form.customer) }}
{{ form_row(form.project) }}
{{ form_row(form.activity) }}
{{ form_row(form.users) }}
{{ form_row(form.tags) }}
{{ form_row(form.exported) }}
{{ form_row(form.template) }}
{{ form_row(form.markAsExported) }}
{% endblock %}
{% block box_footer%}
{{ form_widget(form.create, {'attr': {'class': 'btn btn-success'}}) }}
{{ form_widget(form.preview, {'attr': {'data-toggle': 'tooltip', 'title': 'limited_entries'|trans({'%max%': preview_max})}}) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %}
{% else %}
{{ widgets.callout('danger', 'http_error_403.suggestion'|trans({}, 'exceptions')) }}
{% endif %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{{ widgets.callout('success', 'invoice.preview') }}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set duration = entry.duration|duration %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}{# FIXME fixed rates #}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|escape|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-center">{{ rate|money(model.calculator.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }} text-center text-nowrap">{{ duration }}</td>
<td class="text-right text-nowrap">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% if is_granted('create_invoice') %}
<div class="row no-print">
<div class="col-xs-12">
<a href="{{ path('invoice_print') }}" class="btn btn-success pull-right toolbar-action" data-method="POST">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</a>
</div>
</div>
{% if preview_show %}
{% if model.calculator is empty or model.calculator.entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{% else %}
{% set entries = model.calculator.entries %}
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
{% set duration = entry.duration|duration %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}{# FIXME fixed rates #}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = entry.user.preferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td class="text-nowrap">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.label_user(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|escape|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.project.name }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-center">{{ rate|money(model.calculator.currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }} text-center text-nowrap">{{ duration }}</td>
<td class="text-right text-nowrap">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries) }}
{% endif %}
{% endif %}

View File

@@ -1,7 +1,7 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/actions.html.twig" as actions %}
{% import "invoice/actions.html.twig" as actions %}
{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %}
{% block page_subtitle %}{{ 'admin_invoice_template.subtitle'|trans }}{% endblock %}
@@ -25,28 +25,16 @@
{{ tables.data_table_header(tableName, columns) }}
{% for entry in entries %}
<tr{% if is_granted('edit', entry) %} class="modal-ajax-form open-edit" data-href="{{ path('admin_invoice_template_edit', {'id' : entry.id, 'page': page}) }}"{% endif %}>
<tr class="modal-ajax-form open-edit" data-href="{{ path('admin_invoice_template_edit', {'id' : entry.id}) }}">
<td>{{ entry.name }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'title') }}">{{ entry.title }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_days') }}">{{ entry.dueDays }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'vat') }}">{{ entry.vat }}</td>
<td class="actions">
{# TODO move me to actions macro #}
{% set actions = {} %}
{% if is_granted('edit', entry) %}
{% set actions = {'edit': {'url': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page}), 'class': 'modal-ajax-form'}} %}
{% endif %}
{% if is_granted('create_invoice_template') %}
{% set actions = actions|merge({'copy': path('admin_invoice_template_copy', {'id' : entry.id, 'page': page})}) %}
{% endif %}
{% if is_granted('delete', entry) %}
{% set actions = actions|merge({'trash': path('admin_invoice_template_delete', {'id' : entry.id, 'page': page})}) %}
{% endif %}
{% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index', 'template': entry}) %}
{{ widgets.table_actions(actions) }}
{{- actions.invoice_template(entry, 'index') -}}
</td>
</tr>
{% endfor %}
{{ tables.data_table_footer(entries, 'admin_invoice_template_paginated') }}
{{ tables.data_table_footer(entries, 'admin_invoice_template') }}
{% endblock %}

View File

@@ -439,51 +439,6 @@
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro export(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'filter': '#collapseExport', 'visibility': '#modal_export', 'off': {'id':'export-toggle-button'}} %}
{% set actions = actions|merge({'help': {'url': 'export.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.export', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro invoices(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {'filter': '#collapseInvoice', 'visibility': '#modal_invoice'} %}
{% if is_granted('create_invoice_template') %}
{% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %}
{% endif %}
{% if is_granted('view_invoice_template') %}
{% set actions = actions|merge({'list': path('admin_invoice_template')}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoices', {'actions': actions, 'view': view}) %}
{{ widgets.page_actions(event.payload.actions) }}
{% endmacro %}
{% macro invoice_templates(view) %}
{% import "macros/widgets.html.twig" as widgets %}
{% set actions = {} %}
{% if is_granted('view_invoice') %}
{% set actions = actions|merge({'back': path('invoice')}) %}
{% endif %}
{% if is_granted('create_invoice_template') %}
{% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %}
{% endif %}
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
{% set event = trigger('actions.invoice_templates', {'actions': actions, 'view': 'index'}) %}
{{ widgets.page_actions(actions) }}
{% endmacro %}
{% macro teams(view) %}
{% import "macros/widgets.html.twig" as widgets %}

View File

@@ -9,8 +9,10 @@
namespace App\Tests\Controller;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
/**
* @group integration
@@ -27,7 +29,7 @@ class ExportControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/export/');
$this->request($client, '/export/?preview=');
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client);
@@ -47,7 +49,7 @@ class ExportControllerTest extends ControllerBaseTest
;
$this->importFixture($em, $fixture);
$this->request($client, '/export/');
$this->request($client, '/export/?preview=');
$this->assertTrue($client->getResponse()->isSuccessful());
// make sure all existing records are displayed
@@ -55,7 +57,7 @@ class ExportControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_export', 20);
// assert export type buttons are available
$expected = ['csv', 'html', 'pdf', 'ods', 'xlsx'];
$expected = ['csv', 'html', 'pdf', 'xlsx'];
$node = $client->getCrawler()->filter('#export-buttons button');
$this->assertEquals(count($expected), $node->count());
foreach ($node->getIterator() as $button) {
@@ -100,6 +102,7 @@ class ExportControllerTest extends ControllerBaseTest
public function testExportAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month');
@@ -121,7 +124,8 @@ class ExportControllerTest extends ControllerBaseTest
// don't add daterange to make sure the current month is the default range
$client->submit($form, [
'type' => 'html'
'type' => 'html',
'markAsExported' => 1
]);
$response = $client->getResponse();
@@ -137,5 +141,11 @@ class ExportControllerTest extends ControllerBaseTest
$node = $client->getCrawler()->filter('section.export div#export-records table.dataTable tbody tr');
// 20 rows + the summary footer
$this->assertEquals(21, $node->count());
$timesheets = $em->getRepository(Timesheet::class)->findAll();
/** @var Timesheet $timesheet */
foreach ($timesheets as $timesheet) {
$this->assertTrue($timesheet->isExported());
}
}
}

View File

@@ -10,10 +10,12 @@
namespace App\Tests\Controller;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Form\Type\DateRangeType;
use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
use Doctrine\ORM\EntityManager;
/**
* @group integration
@@ -28,7 +30,7 @@ class InvoiceControllerTest extends ControllerBaseTest
public function testIndexActionRedirectsToCreateTemplate()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/invoice/');
$this->assertIsRedirect($client, '/invoice/template/create');
@@ -42,7 +44,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/');
$this->request($client, '/invoice/?preview=');
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client);
@@ -50,7 +52,7 @@ class InvoiceControllerTest extends ControllerBaseTest
public function testListTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
@@ -64,7 +66,7 @@ class InvoiceControllerTest extends ControllerBaseTest
public function testCreateTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/invoice/template/create');
$this->assertTrue($client->getResponse()->isSuccessful());
@@ -88,7 +90,7 @@ class InvoiceControllerTest extends ControllerBaseTest
public function testCopyTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
@@ -117,6 +119,7 @@ class InvoiceControllerTest extends ControllerBaseTest
public function testPrintAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
@@ -138,66 +141,50 @@ class InvoiceControllerTest extends ControllerBaseTest
$dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d');
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'user' => '',
'daterange' => $dateRange,
'customer' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
// no datatable should be displayed
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
$node = $client->getCrawler()->filter('div.callout.callout-success.lead');
$this->assertNotEmpty($node->text());
$this->assertContains('This is a preview of the data that will show up in your invoice document.', $node->text());
// but the datatable with all timesheets
$this->assertDataTableRowCount($client, 'datatable_invoice', 20);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/print'));
$node->setAttribute('method', 'POST');
$node->setAttribute('action', $this->createUrl('/invoice/?create='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => 1,
'user' => '',
'daterange' => $dateRange,
'customer' => 1,
'project' => 1,
'markAsExported' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('body');
$this->assertEquals(1, $node->count());
$this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class'));
}
public function testPrintActionRedirectsToCreateTemplate()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/invoice/print', 'POST');
$this->assertIsRedirect($client, '/invoice/template/create');
}
public function testPrintActionRedirectsToIndex()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/print', 'POST');
$this->assertIsRedirect($client, '/invoice/');
$timesheets = $em->getRepository(Timesheet::class)->findAll();
/** @var Timesheet $timesheet */
foreach ($timesheets as $timesheet) {
$this->assertTrue($timesheet->isExported());
}
}
public function testEditTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
@@ -231,8 +218,8 @@ class InvoiceControllerTest extends ControllerBaseTest
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/template/1/delete?page=1');
$this->assertIsRedirect($client, '/invoice/template/page/1');
$this->request($client, '/invoice/template/1/delete');
$this->assertIsRedirect($client, '/invoice/template');
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());

View File

@@ -210,6 +210,6 @@ class UserControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/user/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 84);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 80);
}
}

View File

@@ -20,7 +20,6 @@ use App\Entity\User;
use App\Export\RendererInterface;
use App\Repository\Query\TimesheetQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use App\Utils\LocaleSettings;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
@@ -52,9 +51,8 @@ abstract class AbstractRendererTest extends KernelTestCase
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
$dateExtension = new DateExtensions($localeSettings);
$extensions = new Extensions($localeSettings);
return new $classname($translator, $dateExtension, $extensions);
return new $classname($translator, $dateExtension);
}
/**

View File

@@ -32,7 +32,7 @@ class CsvRendererTest extends AbstractRendererTest
public function getTestModel()
{
return [
['01:50 h', '€2,437.12', '1,947.99', 7, 5, 1, 2, 2]
['400', '2437.12', ' EUR 1,947.99 ', 7, 5, 1, 2, 2]
];
}
@@ -76,26 +76,27 @@ class CsvRendererTest extends AbstractRendererTest
}
$expected = [
0 => '2019.06.16 12:00',
1 => '2019.06.16 12:06',
2 => 'kevin',
3 => 'Customer Name',
4 => 'project name',
5 => 'activity description',
6 => '',
7 => '',
8 => 'foo,bar',
9 => 'meta-bar',
10 => 'meta-bar2',
11 => '€0.00',
12 => '€84.00',
13 => '00:06 h',
14 => '€0.00',
0 => '2019-06-16',
1 => '12:00',
2 => '12:06',
3 => '400',
4 => '0',
5 => 'kevin',
6 => 'Customer Name',
7 => 'project name',
8 => 'activity description',
9 => '',
10 => '',
11 => 'foo,bar',
12 => '',
13 => ' EUR 84.00 ',
14 => 'meta-bar',
15 => 'meta-bar2',
];
self::assertEquals(7, count($all));
self::assertEquals(count($expected), count($all[0]));
self::assertEquals('foo', $all[4][8]);
self::assertEquals('foo', $all[4][11]);
self::assertEquals($expected, $all[5]);
}

View File

@@ -1,52 +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\Tests\Export\Renderer;
use App\Export\Renderer\OdsRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Export\Renderer\OdsRenderer
* @covers \App\Export\Renderer\AbstractSpreadsheetRenderer
* @covers \App\Export\Renderer\RendererTrait
* @group integration
*/
class OdsRendererTest extends AbstractRendererTest
{
public function testConfiguration()
{
$sut = $this->getAbstractRenderer(OdsRenderer::class);
$this->assertEquals('ods', $sut->getId());
$this->assertEquals('ods', $sut->getTitle());
$this->assertEquals('ods', $sut->getIcon());
}
public function testRender()
{
$sut = $this->getAbstractRenderer(OdsRenderer::class);
/** @var BinaryFileResponse $response */
$response = $this->render($sut);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=kimai-export.ods', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -51,7 +51,7 @@ class DebugRendererTest extends TestCase
// TODO check values or formats?
}
protected function assertModelStructure(array $model, $hasProject = true, $hasActivity = false)
protected function assertModelStructure(array $model, $hasProject = true)
{
$keys = [
'invoice.due_date',
@@ -85,10 +85,14 @@ class DebugRendererTest extends TestCase
'customer.number',
'customer.homepage',
'customer.comment',
'customer.fixed_rate',
'customer.hourly_rate',
'customer.meta.foo-customer',
'activity.id',
'activity.name',
'activity.comment',
'activity.fixed_rate',
'activity.hourly_rate',
'activity.meta.foo-activity',
];
@@ -98,18 +102,12 @@ class DebugRendererTest extends TestCase
'project.name',
'project.comment',
'project.order_number',
'project.fixed_rate',
'project.hourly_rate',
'project.meta.foo-project',
]);
}
if ($hasActivity) {
$keys = array_merge($keys, [
'activity.id',
'activity.name',
'activity.comment',
]);
}
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);

View File

@@ -37,6 +37,15 @@ class ExportQueryTest extends BaseQueryTest
$this->assertState($sut);
$this->assertExported($sut);
$this->assertType($sut);
$this->assertMarkAsExported($sut);
}
protected function assertMarkAsExported(ExportQuery $sut)
{
$this->assertFalse($sut->isMarkAsExported());
$sut->setMarkAsExported(true);
$this->assertTrue($sut->isMarkAsExported());
}
protected function assertUser(ExportQuery $sut)

View File

@@ -0,0 +1,123 @@
<?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\Tests\Repository\Query;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\Query\InvoiceQuery;
/**
* @covers \App\Repository\Query\InvoiceQuery
*/
class InvoiceQueryTest extends BaseQueryTest
{
public function testQuery()
{
$sut = new InvoiceQuery();
$this->assertResultType($sut);
$this->assertPage($sut);
$this->assertPageSize($sut);
$this->assertOrderBy($sut, 'begin');
$this->assertOrder($sut, InvoiceQuery::ORDER_DESC);
$this->assertUser($sut);
$this->assertCustomer($sut);
$this->assertProject($sut);
$this->assertActivity($sut);
$this->assertState($sut);
$this->assertExported($sut);
$this->assertMarkAsExported($sut);
}
protected function assertMarkAsExported(InvoiceQuery $sut)
{
$this->assertFalse($sut->isMarkAsExported());
$sut->setMarkAsExported(true);
$this->assertTrue($sut->isMarkAsExported());
}
protected function assertUser(InvoiceQuery $sut)
{
$this->assertNull($sut->getUser());
$expected = new User();
$expected->setUsername('foo-bar');
$sut->setUser($expected);
$this->assertEquals($expected, $sut->getUser());
}
protected function assertCustomer(InvoiceQuery $sut)
{
$this->assertNull($sut->getCustomer());
$expected = new Customer();
$expected->setName('foo-bar');
$sut->setCustomer($expected);
$this->assertEquals($expected, $sut->getCustomer());
}
protected function assertProject(InvoiceQuery $sut)
{
$this->assertNull($sut->getProject());
$expected = new Project();
$expected->setName('foo-bar');
$sut->setProject($expected);
$this->assertEquals($expected, $sut->getProject());
}
protected function assertActivity(InvoiceQuery $sut)
{
$this->assertNull($sut->getActivity());
$expected = new Activity();
$expected->setName('foo-bar');
$sut->setActivity($expected);
$this->assertEquals($expected, $sut->getActivity());
}
protected function assertState(InvoiceQuery $sut)
{
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getState());
$sut->setState(PHP_INT_MAX);
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getState());
$sut->setState(InvoiceQuery::STATE_STOPPED);
$this->assertEquals(InvoiceQuery::STATE_STOPPED, $sut->getState());
$sut->setState(InvoiceQuery::STATE_RUNNING);
$this->assertEquals(InvoiceQuery::STATE_RUNNING, $sut->getState());
$sut->setState(InvoiceQuery::STATE_ALL);
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getState());
}
protected function assertExported(InvoiceQuery $sut)
{
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getExported());
$sut->setExported(PHP_INT_MAX);
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getExported());
$sut->setExported(InvoiceQuery::STATE_EXPORTED);
$this->assertEquals(InvoiceQuery::STATE_EXPORTED, $sut->getExported());
$sut->setExported(InvoiceQuery::STATE_NOT_EXPORTED);
$this->assertEquals(InvoiceQuery::STATE_NOT_EXPORTED, $sut->getExported());
$sut->setExported(InvoiceQuery::STATE_ALL);
$this->assertEquals(InvoiceQuery::STATE_ALL, $sut->getExported());
}
}

View File

@@ -31,6 +31,7 @@ class TimesheetQueryTest extends BaseQueryTest
$this->assertOrder($sut, TimesheetQuery::ORDER_DESC);
$this->assertUser($sut);
$this->assertUsers($sut);
$this->assertCustomer($sut);
$this->assertProject($sut);
$this->assertActivity($sut);
@@ -48,6 +49,30 @@ class TimesheetQueryTest extends BaseQueryTest
$this->assertEquals($expected, $sut->getUser());
}
protected function assertUsers(TimesheetQuery $sut)
{
$this->assertEmpty($sut->getUsers());
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(1);
$sut->addUser($user);
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(1);
$sut->addUser($user);
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(13);
$sut->addUser($user);
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(27);
$sut->addUser($user);
$sut->removeUser($user);
$this->assertCount(2, $sut->getUsers());
}
protected function assertCustomer(TimesheetQuery $sut)
{
$this->assertNull($sut->getCustomer());

View File

@@ -73,7 +73,7 @@ abstract class AbstractVoterTest extends TestCase
$customers = ['view_customer', 'edit_customer', 'budget_customer', 'delete_customer', 'create_customer'];
$customersTeam = ['view_customer', 'edit_teamlead_customer', 'budget_teamlead_customer'];
$invoice = ['view_invoice', 'create_invoice'];
$invoiceTemplate = ['view_invoice_template', 'create_invoice_template', 'edit_invoice_template', 'delete_invoice_template'];
$invoiceTemplate = ['manage_invoice_template'];
$timesheet = ['view_own_timesheet', 'start_own_timesheet', 'stop_own_timesheet', 'create_own_timesheet', 'edit_own_timesheet', 'export_own_timesheet', 'delete_own_timesheet'];
$timesheetOthers = ['view_other_timesheet', 'start_other_timesheet', 'stop_other_timesheet', 'create_other_timesheet', 'edit_other_timesheet', 'export_other_timesheet', 'delete_other_timesheet'];
$profile = ['view_own_profile', 'edit_own_profile', 'password_own_profile', 'preferences_own_profile', 'api-token_own_profile'];

View File

@@ -1,66 +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\Tests\Voter;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Voter\InvoiceTemplateVoter;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
/**
* @covers \App\Voter\InvoiceTemplateVoter
*/
class InvoiceTemplateVoterTest extends AbstractVoterTest
{
/**
* @dataProvider getTestData
*/
public function testVote(User $user, $subject, $attribute, $result)
{
$token = new UsernamePasswordToken($user, 'foo', 'bar', $user->getRoles());
$sut = $this->getVoter(InvoiceTemplateVoter::class, $user);
$this->assertEquals($result, $sut->vote($token, $subject, [$attribute]));
}
public function getTestData()
{
$user0 = $this->getUser(0, null);
$user1 = $this->getUser(1, User::ROLE_USER);
$user2 = $this->getUser(2, User::ROLE_TEAMLEAD);
$user3 = $this->getUser(3, User::ROLE_ADMIN);
$user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN);
$result = VoterInterface::ACCESS_GRANTED;
foreach ([$user3, $user4] as $user) {
yield [$user, new InvoiceTemplate(), 'view', $result];
yield [$user, new InvoiceTemplate(), 'edit', $result];
yield [$user, new InvoiceTemplate(), 'delete', $result];
}
$result = VoterInterface::ACCESS_DENIED;
foreach ([$user0, $user1, $user2] as $user) {
yield [$user, new InvoiceTemplate(), 'view', $result];
yield [$user, new InvoiceTemplate(), 'edit', $result];
yield [$user, new InvoiceTemplate(), 'delete', $result];
}
$result = VoterInterface::ACCESS_ABSTAIN;
foreach ([$user0, $user1, $user2] as $user) {
yield [$user, new InvoiceTemplate(), 'view_invoice_template', $result];
yield [$user, new InvoiceTemplate(), 'edit_invoice_template', $result];
yield [$user, new InvoiceTemplate(), 'delete_invoice_template', $result];
yield [$user, new \stdClass(), 'view', $result];
yield [$user, null, 'edit', $result];
yield [$user, $user, 'delete', $result];
}
}
}

View File

@@ -40,7 +40,7 @@ class RolePermissionVoterTest extends AbstractVoterTest
$user4 = $this->getUser(4, User::ROLE_SUPER_ADMIN);
$invoice = [
'create_invoice_template' => null,
'manage_invoice_template' => null,
'view_invoice' => null,
'create_invoice' => null,
];

View File

@@ -4,6 +4,7 @@ includes:
- %rootDir%/../phpstan-phpunit/extension.neon
parameters:
tmpDir: %rootDir%/../../../var/cache/phpstan
ignoreErrors:
- '#Access to an undefined property Faker\\Generator::\$stateAbbr.#'
- '#Access to an undefined property Faker\\Generator::\$catchPhrase.#'

View File

@@ -601,10 +601,6 @@
<source>invoice.subtitle</source>
<target>م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>هذه معاينة للبيانات التي ستظهر في مستند الفاتورة.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>طباعة</target>

View File

@@ -760,9 +760,17 @@
<source>invoice.subtitle</source>
<target>Hier erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Dies ist eine Vorschau der Daten, die in Ihrer Rechnung enthalten sein werden.</target>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Rechnungsdaten filtern</target>
</trans-unit>
<trans-unit id="limited_entries">
<source>limited_entries</source>
<target>Limitiert auf %max% Einträge</target>
</trans-unit>
<trans-unit id="button.preview">
<source>button.preview</source>
<target>Vorschau</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
@@ -788,6 +796,10 @@
<source>invoice_print</source>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="label.mark_as_exported">
<source>label.mark_as_exported</source>
<target>Als exportiert markieren</target>
</trans-unit>
<trans-unit id="label.template">
<source>label.template</source>
<target>Vorlage</target>
@@ -888,6 +900,10 @@
<source>export.subtitle</source>
<target>Exportieren Sie geloggte Zeiten in veschiedene Zielformate</target>
</trans-unit>
<trans-unit id="export.filter">
<source>export.filter</source>
<target>Daten für Export filtern</target>
</trans-unit>
<trans-unit id="export.period">
<source>export.period</source>
<target>Zeitraum</target>

View File

@@ -760,9 +760,17 @@
<source>invoice.subtitle</source>
<target>Create invoices from your recorded timesheet entries.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>This is a preview of the data that will show up in your invoice document.</target>
<trans-unit id="invoice.filter">
<source>invoice.filter</source>
<target>Filter invoice data</target>
</trans-unit>
<trans-unit id="limited_entries">
<source>limited_entries</source>
<target>Limited to %max% entries</target>
</trans-unit>
<trans-unit id="button.preview">
<source>button.preview</source>
<target>Preview</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
@@ -788,6 +796,10 @@
<source>invoice_print</source>
<target>Invoice</target>
</trans-unit>
<trans-unit id="label.mark_as_exported">
<source>label.mark_as_exported</source>
<target>Mark as exported</target>
</trans-unit>
<trans-unit id="label.template">
<source>label.template</source>
<target>Template</target>
@@ -888,6 +900,10 @@
<source>export.subtitle</source>
<target>Export timesheet data to various formats</target>
</trans-unit>
<trans-unit id="export.filter">
<source>export.filter</source>
<target>Filter data for export</target>
</trans-unit>
<trans-unit id="export.period">
<source>export.period</source>
<target>Period</target>

View File

@@ -740,10 +740,6 @@
<source>invoice.subtitle</source>
<target>Crear facturas a partir de registros de sus partes de horas.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Esta es una vista previa de los datos que se mostrarán en su documento de factura.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Imprimir</target>

View File

@@ -740,10 +740,6 @@
<source>invoice.subtitle</source>
<target>Générer des factures à partir des fiches de temps.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Il s'agit d'un aperçu des données qui apparaîtront dans la facture.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Imprimer</target>

View File

@@ -740,10 +740,6 @@
<source>invoice.subtitle</source>
<target>Számlák készítése az időbejegyzéseidből</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>A számlán szereplő adatok előnézete.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Nyomtatás</target>

View File

@@ -740,10 +740,6 @@
<source>invoice.subtitle</source>
<target>タイムシートに記録したエントリから請求書を作成します。</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>これは請求書に出力されるデータのプレビューです。</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>出力</target>

View File

@@ -740,10 +740,6 @@
<source>invoice.subtitle</source>
<target>Criar faturas a partir de suas entradas de quadro de horários registradas.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Esta é uma prévia dos dados que serão exibidos no documento da fatura.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Imprimir</target>

View File

@@ -754,10 +754,6 @@
<source>invoice.subtitle</source>
<target>Vytvorte faktúry z vašich zaznamenaných položiek časového rozvrhu.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Toto je ukážka údajov, ktoré sa zobrazia vo vašom fakturačnom dokumente.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target>Tlačiť</target>

View File

@@ -739,11 +739,6 @@ För närvarande har %user% användare %records% tidsrekord som räknas upp till
<source>invoice.subtitle</source>
<target state="translated">Skapa fakturor från dina inspelade tidskriftsuppgifter.</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target state="translated">Detta är en förhandsvisning av de data som kommer att visas i ditt fakturatokument.</target>
</trans-unit>
<trans-unit id="button.print">
<source>button.print</source>
<target state="translated">Skriv ut</target>