create multiple invoices at once (#2465)

This commit is contained in:
Kevin Papst
2021-03-27 20:47:53 +01:00
committed by GitHub
parent 87d07ffaaf
commit f8a5ff7315
34 changed files with 754 additions and 378 deletions

View File

@@ -8,6 +8,13 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process. Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation. Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
## [1.14](https://github.com/kevinpapst/kimai2/releases/tag/1.13)
**New database tables and fields: don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
Permission changes:
- `history_invoice` - removed permission entirely
## [1.13](https://github.com/kevinpapst/kimai2/releases/tag/1.13) ## [1.13](https://github.com/kevinpapst/kimai2/releases/tag/1.13)
- Deprecated `now` variable in export templates: create it yourself with `{% set now = create_date('now', app.user) %}` - Deprecated `now` variable in export templates: create it yourself with `{% set now = create_date('now', app.user) %}`

View File

@@ -71,35 +71,38 @@ export default class KimaiDatatableColumnView extends KimaiPlugin {
} }
changeVisibility(columnName, checked) { changeVisibility(columnName, checked) {
const table = document.getElementById('datatable_' + this.id).getElementsByClassName('dataTable')[0]; const tables = document.getElementsByClassName('datatable_' + this.id);
let column = 0; for (let tableBox of tables) {
let foundColumn = false; let column = 0;
for (let columnElement of table.getElementsByTagName('th')) { let foundColumn = false;
if (columnElement.getAttribute('data-field') === columnName) { let table = tableBox.getElementsByClassName('dataTable')[0];
foundColumn = true; for (let columnElement of table.getElementsByTagName('th')) {
break; if (columnElement.getAttribute('data-field') === columnName) {
foundColumn = true;
break;
}
if (columnElement.getAttribute('colspan') !== null) {
console.log('Tables with colspans are not supported!');
}
column++;
} }
if (columnElement.getAttribute('colspan') !== null) { if (!foundColumn) {
console.log('Tables with colspans are not supported!'); return;
} }
column++; for (let rowElement of table.getElementsByTagName('tr')) {
} if (rowElement.children[column] === undefined) {
continue;
}
if (!foundColumn) { if (checked) {
return; rowElement.children[column].classList.remove('hidden');
} } else {
rowElement.children[column].classList.add('hidden');
for (let rowElement of table.getElementsByTagName('tr')) { }
if (rowElement.children[column] === undefined) {
continue;
}
if (checked) {
rowElement.children[column].classList.remove('hidden');
} else {
rowElement.children[column].classList.add('hidden');
} }
} }
} }

View File

@@ -32,4 +32,47 @@ export default class KimaiForm extends KimaiPlugin {
this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector); this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector);
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector); this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
} }
getFormData(form) {
let serialized = [];
// Loop through each field in the form
for (let i = 0; i < form.elements.length; i++) {
let field = form.elements[i];
// Don't serialize a couple of field types (button and submit are important to exclude, eg. invoice preview would fail otherwise)
if (!field.name || field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') {
continue;
}
// If a multi-select, get all selections
if (field.type === 'select-multiple') {
for (var n = 0; n < field.options.length; n++) {
if (!field.options[n].selected) continue;
serialized.push({
name: field.name,
value: field.options[n].value
});
}
} else if ((field.type !== 'checkbox' && field.type !== 'radio') || field.checked) {
serialized.push({
name: field.name,
value: field.value
});
}
}
return serialized;
}
convertFormDataToQueryString(formData) {
let serialized = [];
for (let row of formData) {
serialized.push(encodeURIComponent(row.name) + "=" + encodeURIComponent(row.value));
}
return serialized.join('&');
}
} }

View File

@@ -106,7 +106,7 @@ kimai:
CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','comments_create_team_customer','details_team_customer'] CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','comments_create_team_customer','details_team_customer']
CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer'] CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer']
INVOICE: ['view_invoice','create_invoice'] INVOICE: ['view_invoice','create_invoice']
INVOICE_ADMIN: ['manage_invoice_template','history_invoice'] INVOICE_ADMIN: ['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: ['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'] 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'] PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.098eaae1.js", "build/runtime.098eaae1.js",
"build/0.79dbdbb9.js", "build/0.79dbdbb9.js",
"build/1.32489d92.js", "build/1.32489d92.js",
"build/app.b19b6399.js" "build/app.0d1f3758.js"
], ],
"css": [ "css": [
"build/app.b22111d1.css" "build/app.b22111d1.css"
@@ -53,7 +53,7 @@
"build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5", "build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
"build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP", "build/1.32489d92.js": "sha384-wVkjh5FzjFhMV4S4uNP23E/OLBOf+Zi7t3lpm9eWzoMr/tm2pydT+q0Op1XHuoUP",
"build/app.b19b6399.js": "sha384-giiZt81GRzPg3SJzQLRg7+bLAhpzPPP0XCqzZBcznH/JfE3Z3wQElGPKuJR84RXl", "build/app.0d1f3758.js": "sha384-lptsmHCgW8drPM/w0avBAIS0Dgsg4qQm+RQnxVeMMkmTl9kSy2UfJGaNUfncPH3x",
"build/app.b22111d1.css": "sha384-ZMJkA2sXnH7J/uMTKDBf8+bLNHulT+yZiz1h5fxzxJshqw9Ccs3brjAJPqb+0/6S", "build/app.b22111d1.css": "sha384-ZMJkA2sXnH7J/uMTKDBf8+bLNHulT+yZiz1h5fxzxJshqw9Ccs3brjAJPqb+0/6S",
"build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f", "build/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh", "build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",

View File

@@ -3,7 +3,7 @@
"build/1.32489d92.js": "build/1.32489d92.js", "build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js", "build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"build/app.css": "build/app.b22111d1.css", "build/app.css": "build/app.b22111d1.css",
"build/app.js": "build/app.b19b6399.js", "build/app.js": "build/app.0d1f3758.js",
"build/calendar.css": "build/calendar.1408f57e.css", "build/calendar.css": "build/calendar.1408f57e.css",
"build/calendar.js": "build/calendar.070aab88.js", "build/calendar.js": "build/calendar.070aab88.js",
"build/chart.js": "build/chart.34d60a88.js", "build/chart.js": "build/chart.34d60a88.js",

View File

@@ -19,8 +19,8 @@ use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use App\Utils\SearchTerm; use App\Utils\SearchTerm;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\Table;
@@ -39,10 +39,6 @@ class InvoiceCreateCommand extends Command
* @var ServiceInvoice * @var ServiceInvoice
*/ */
private $serviceInvoice; private $serviceInvoice;
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/** /**
* @var CustomerRepository * @var CustomerRepository
*/ */
@@ -70,7 +66,6 @@ class InvoiceCreateCommand extends Command
public function __construct( public function __construct(
ServiceInvoice $serviceInvoice, ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository, CustomerRepository $customerRepository,
ProjectRepository $projectRepository, ProjectRepository $projectRepository,
InvoiceTemplateRepository $invoiceTemplateRepository, InvoiceTemplateRepository $invoiceTemplateRepository,
@@ -78,7 +73,6 @@ class InvoiceCreateCommand extends Command
EventDispatcherInterface $eventDispatcher EventDispatcherInterface $eventDispatcher
) { ) {
$this->serviceInvoice = $serviceInvoice; $this->serviceInvoice = $serviceInvoice;
$this->timesheetRepository = $timesheetRepository;
$this->customerRepository = $customerRepository; $this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository; $this->projectRepository = $projectRepository;
$this->invoiceTemplateRepository = $invoiceTemplateRepository; $this->invoiceTemplateRepository = $invoiceTemplateRepository;
@@ -99,7 +93,7 @@ class InvoiceCreateCommand extends Command
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices') ->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null) ->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)
->addOption('end', null, InputOption::VALUE_OPTIONAL, 'End date (format: 2020-01-31, default: end of the month)', null) ->addOption('end', null, InputOption::VALUE_OPTIONAL, 'End date (format: 2020-01-31, default: end of the month)', null)
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query', date_default_timezone_get()) ->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query (fallback: users timezone)', null)
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of customer IDs', null) ->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of customer IDs', null)
->addOption('project', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of project IDs', null) ->addOption('project', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of project IDs', null)
->addOption('by-customer', null, InputOption::VALUE_NONE, 'If set, one invoice for each active customer in the given timerange is created') ->addOption('by-customer', null, InputOption::VALUE_NONE, 'If set, one invoice for each active customer in the given timerange is created')
@@ -157,7 +151,13 @@ class InvoiceCreateCommand extends Command
return 1; return 1;
} }
$timezone = new \DateTimeZone($input->getOption('timezone')); $timezone = $input->getOption('timezone');
if ($timezone === null) {
$timezone = $user->getTimezone();
}
$timezone = new \DateTimeZone($timezone);
$dateFactory = new DateTimeFactory($timezone);
if (!empty($input->getOption('start')) && empty($input->getOption('end'))) { if (!empty($input->getOption('start')) && empty($input->getOption('end'))) {
$io->error('You need to supply a end date if a start date was given'); $io->error('You need to supply a end date if a start date was given');
@@ -191,7 +191,7 @@ class InvoiceCreateCommand extends Command
$start = $input->getOption('start'); $start = $input->getOption('start');
if (!empty($start)) { if (!empty($start)) {
try { try {
$start = new \DateTime($start, $timezone); $start = $dateFactory->createDateTime($start);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Invalid start date given'); $io->error('Invalid start date given');
@@ -199,14 +199,14 @@ class InvoiceCreateCommand extends Command
} }
} }
if (!$start instanceof \DateTime) { if (!$start instanceof \DateTime) {
$start = new \DateTime('first day of this month', $timezone); $start = $dateFactory->getStartOfMonth();
} }
$start->setTime(0, 0, 0); $start->setTime(0, 0, 0);
$end = $input->getOption('end'); $end = $input->getOption('end');
if (!empty($end)) { if (!empty($end)) {
try { try {
$end = new \DateTime($end, $timezone); $end = $dateFactory->createDateTime($end);
} catch (\Exception $ex) { } catch (\Exception $ex) {
$io->error('Invalid end date given'); $io->error('Invalid end date given');
@@ -214,7 +214,7 @@ class InvoiceCreateCommand extends Command
} }
} }
if (!$end instanceof \DateTime) { if (!$end instanceof \DateTime) {
$end = new \DateTime('last day of this month', $timezone); $end = $dateFactory->getEndOfMonth();
} }
$end->setTime(23, 59, 59); $end->setTime(23, 59, 59);
@@ -519,7 +519,7 @@ class InvoiceCreateCommand extends Command
*/ */
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
{ {
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery); $results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$customers = []; $customers = [];
@@ -537,7 +537,7 @@ class InvoiceCreateCommand extends Command
*/ */
private function getActiveProjects(InvoiceQuery $invoiceQuery): array private function getActiveProjects(InvoiceQuery $invoiceQuery): array
{ {
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery); $results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$projects = []; $projects = [];

View File

@@ -209,10 +209,8 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$form->submit($submitData, false); $form->submit($submitData, false);
if (!$form->isValid()) { if (!$form->isValid()) {
$data->resetByFormError($form->getErrors()); $data->resetByFormError($form->getErrors(true));
} } elseif ($request->query->has('setDefaultQuery')) {
if ($request->query->has('setDefaultQuery')) {
$params = []; $params = [];
foreach ($form->all() as $name => $child) { foreach ($form->all() as $name => $child) {
$params[$name] = $child->getViewData(); $params[$name] = $child->getViewData();

View File

@@ -10,6 +10,7 @@
namespace App\Controller; namespace App\Controller;
use App\Configuration\SystemConfiguration; use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice; use App\Entity\Invoice;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Export\Spreadsheet\AnnotatedObjectExporter; use App\Export\Spreadsheet\AnnotatedObjectExporter;
@@ -30,7 +31,6 @@ use App\Repository\Query\InvoiceQuery;
use Exception; use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -83,45 +83,30 @@ final class InvoiceController extends AbstractController
$this->flashWarning('invoice.first_template'); $this->flashWarning('invoice.first_template');
} }
$model = null;
$query = $this->getDefaultQuery(); $query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form')); $form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->setData($query); if ($this->handleSearch($form, $request)) {
$form->submit($request->query->all(), false); return $this->redirectToRoute('invoice');
}
if ($this->isGranted('create_invoice') && $form->isValid()) { $models = [];
// use the current request locale as fallback, if no translation was configured $total = 0;
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) { $searched = false;
$query->getTemplate()->setLanguage($request->getLocale());
}
try { if ($form->isValid() && $this->isGranted('create_invoice')) {
/** @var SubmitButton $createButton */ if ($request->query->has('createInvoice')) {
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query);
}
/** @var SubmitButton $printButton */
$printButton = $form->get('print');
if ($printButton->isClicked()) {
return $this->service->renderInvoice($query, $this->dispatcher);
}
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
}
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
try { try {
$model = $this->service->createModel($query); return $this->renderInvoice($query, $request);
$entries = $this->service->findInvoiceItems($query); } catch (Exception $ex) {
if (!empty($entries)) { $this->logException($ex);
$model->addEntries($entries); $this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
} }
}
if ($form->get('template')->getData() !== null) {
try {
$models = $this->service->createModels($query);
$searched = true;
} catch (Exception $ex) { } catch (Exception $ex) {
$this->logException($ex); $this->logException($ex);
$this->flashError($ex->getMessage()); $this->flashError($ex->getMessage());
@@ -129,14 +114,73 @@ final class InvoiceController extends AbstractController
} }
} }
foreach ($models as $model) {
$total += \count($model->getCalculator()->getEntries());
}
return $this->render('invoice/index.html.twig', [ return $this->render('invoice/index.html.twig', [
'query' => $query, 'models' => $models,
'model' => $model,
'form' => $form->createView(), 'form' => $form->createView(),
'limit_preview' => ($total > 500),
'searched' => $searched,
]); ]);
} }
protected function getDefaultQuery(): InvoiceQuery /**
* @Route(path="/preview/{customer}/{template}", name="invoice_preview", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function previewAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($form->isValid() && $this->isGranted('create_invoice')) {
try {
$query->setTemplate($template);
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
return $this->service->renderInvoiceWithModel($model, $this->dispatcher);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'Failed generating invoice preview: ' . $ex->getMessage()]);
}
}
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/save-invoice/{customer}/{template}", name="invoice_create", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($form->isValid() && $this->isGranted('create_invoice')) {
$query->setTemplate($template);
$query->setCustomers([$customer]);
return $this->renderInvoice($query, $request);
}
return $this->redirectToRoute('invoice');
}
private function getDefaultQuery(): InvoiceQuery
{ {
$factory = $this->getDateTimeFactory(); $factory = $this->getDateTimeFactory();
$begin = $factory->getStartOfMonth(); $begin = $factory->getStartOfMonth();
@@ -159,20 +203,23 @@ final class InvoiceController extends AbstractController
return $query; return $query;
} }
protected function renderInvoice(InvoiceQuery $query) private function renderInvoice(InvoiceQuery $query, Request $request)
{ {
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
try { try {
$invoice = $this->service->createInvoice($query, $this->dispatcher); $invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success'); $this->flashSuccess('action.update.success');
if ($this->isGranted('history_invoice')) { if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]); return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
} }
$file = $this->service->getInvoiceFile($invoice); return $this->redirectToRoute('admin_invoice_list');
return $this->file($file->getRealPath(), $file->getBasename());
} catch (Exception $ex) { } catch (Exception $ex) {
$this->flashUpdateException($ex); $this->flashUpdateException($ex);
} }
@@ -182,7 +229,6 @@ final class InvoiceController extends AbstractController
/** /**
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"}) * @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/ */
public function changeStatusAction(Invoice $invoice, string $status): Response public function changeStatusAction(Invoice $invoice, string $status): Response
{ {
@@ -198,7 +244,6 @@ final class InvoiceController extends AbstractController
/** /**
* @Route(path="/delete/{id}", name="admin_invoice_delete", methods={"GET"}) * @Route(path="/delete/{id}", name="admin_invoice_delete", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/ */
public function deleteInvoiceAction(Invoice $invoice): Response public function deleteInvoiceAction(Invoice $invoice): Response
{ {
@@ -214,7 +259,6 @@ final class InvoiceController extends AbstractController
/** /**
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"}) * @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/ */
public function downloadAction(Invoice $invoice): Response public function downloadAction(Invoice $invoice): Response
{ {
@@ -231,7 +275,6 @@ final class InvoiceController extends AbstractController
/** /**
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"}) * @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/ */
public function showInvoicesAction(Request $request, int $page): Response public function showInvoicesAction(Request $request, int $page): Response
{ {
@@ -380,10 +423,6 @@ final class InvoiceController extends AbstractController
*/ */
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
{ {
if (!$this->templateRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
if (null !== $copyFrom) { if (null !== $copyFrom) {
@@ -410,7 +449,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_template'); return $this->redirectToRoute('admin_invoice_template');
} }
protected function renderTemplateForm(InvoiceTemplate $template, Request $request): Response private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{ {
$editForm = $this->createEditForm($template); $editForm = $this->createEditForm($template);
@@ -433,7 +472,7 @@ final class InvoiceController extends AbstractController
]); ]);
} }
protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface private function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
{ {
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class; $form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;

View File

@@ -30,15 +30,13 @@ class InvoiceSubscriber extends AbstractActionsSubscriber
return; return;
} }
if ($this->isGranted('history_invoice')) { if ($invoice->isNew()) {
if ($invoice->isNew()) { $event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]);
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]); } elseif ($invoice->isPending()) {
} elseif ($invoice->isPending()) { $event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid'])]);
$event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid'])]);
}
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId()]), false);
} }
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId()]), false);
} }
} }

View File

@@ -22,9 +22,7 @@ class InvoicesSubscriber extends AbstractActionsSubscriber
{ {
$event->addColumnToggle('#modal_invoice'); $event->addColumnToggle('#modal_invoice');
if ($this->isGranted('history_invoice')) { $event->addAction('list', ['url' => $this->path('admin_invoice_list')]);
$event->addAction('list', ['url' => $this->path('admin_invoice_list')]);
}
if ($this->isGranted('manage_invoice_template')) { if ($this->isGranted('manage_invoice_template')) {
$event->addAction('invoice-template', ['url' => $this->path('admin_invoice_template')]); $event->addAction('invoice-template', ['url' => $this->path('admin_invoice_template')]);

View File

@@ -50,6 +50,10 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$extendedOptions = ['class' => 'selectpicker']; $extendedOptions = ['class' => 'selectpicker'];
if ($options['multiple']) {
$extendedOptions['size'] = 1;
}
if (false !== $options['width']) { if (false !== $options['width']) {
$extendedOptions['data-width'] = $options['width']; $extendedOptions['data-width'] = $options['width'];
} }

View File

@@ -12,7 +12,6 @@ namespace App\Form\Toolbar;
use App\Form\Type\InvoiceTemplateType; use App\Form\Type\InvoiceTemplateType;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -28,22 +27,12 @@ class InvoiceToolbarSimpleForm extends AbstractToolbarForm
{ {
$this->addTemplateChoice($builder); $this->addTemplateChoice($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']]); $this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true); $this->addCustomerMultiChoice($builder, ['required' => false, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
$this->addProjectMultiChoice($builder, ['ignore_date' => true], false, true); $this->addProjectMultiChoice($builder, ['ignore_date' => true], false, true);
$builder->add('markAsExported', CheckboxType::class, [ $builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported', 'label' => 'label.mark_as_exported',
'required' => false, 'required' => false,
]); ]);
$builder->add('create', SubmitType::class, [
'label' => 'action.save',
]);
$builder->add('print', SubmitType::class, [
'label' => 'button.preview_print',
'attr' => ['formtarget' => '_blank'],
]);
$builder->add('preview', SubmitType::class, [
'label' => 'button.preview',
]);
} }
protected function addTemplateChoice(FormBuilderInterface $builder) protected function addTemplateChoice(FormBuilderInterface $builder)

View File

@@ -21,21 +21,9 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$total = $model->getCalculator()->getTotal(); $total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal(); $subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter(); $formatter = $model->getFormatter();
$entries = $model->getCalculator()->getEntries();
$begin = null; $begin = $model->getQuery()->getBegin();
if ($model->getQuery()->getBegin() !== null) { $end = $model->getQuery()->getEnd();
$begin = $model->getQuery()->getBegin();
} elseif (!empty($entries)) {
$begin = $entries[0];
}
$end = null;
if ($model->getQuery()->getEnd() !== null) {
$end = $model->getQuery()->getEnd();
} elseif (!empty($entries)) {
$end = array_keys($entries)[\count($entries) - 1];
}
$values = [ $values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()), 'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),

View File

@@ -90,7 +90,9 @@ final class ServiceInvoice
{ {
foreach ($this->getNumberGenerator() as $generator) { foreach ($this->getNumberGenerator() as $generator) {
if ($generator->getId() === $name) { if ($generator->getId() === $name) {
return $generator; // several models can co-exist at the same time and NumberGeneratorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $generator;
} }
} }
@@ -116,7 +118,9 @@ final class ServiceInvoice
{ {
foreach ($this->getCalculator() as $calculator) { foreach ($this->getCalculator() as $calculator) {
if ($calculator->getId() === $name) { if ($calculator->getId() === $name) {
return $calculator; // several models can co-exist at the same time and CalculatorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $calculator;
} }
} }
@@ -258,50 +262,33 @@ final class ServiceInvoice
/** /**
* @param InvoiceQuery $query * @param InvoiceQuery $query
* @return array<string, InvoiceItemInterface[]> * @return InvoiceItemInterface[]
*/ */
private function findInvoiceItemsWithRepository(InvoiceQuery $query): array public function findInvoiceItems(InvoiceQuery $query): array
{ {
@trigger_error('Using findInvoiceItems() is deprecated since 1.14 and will be removed with 2.0', E_USER_DEPRECATED);
// customer needs to be defined, as we need the currency for the invoice // customer needs to be defined, as we need the currency for the invoice
if (!$query->hasCustomers()) { if (!$query->hasCustomers()) {
return []; return [];
} }
$factory = $this->getDateTimeFactory($query); return $this->getInvoiceItems($query);
if (null === $query->getBegin()) {
$query->setBegin($factory->getStartOfMonth());
}
if (null === $query->getEnd()) {
$query->setEnd($factory->getEndOfMonth());
}
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$repositories = $this->getInvoiceItemRepositories();
$items = [];
foreach ($repositories as $repository) {
$items[\get_class($repository)] = $repository->getInvoiceItemsForQuery($query);
}
return $items;
} }
/** /**
* @param InvoiceQuery $query * @param InvoiceQuery $query
* @return InvoiceItemInterface[] * @return InvoiceItemInterface[]
*/ */
public function findInvoiceItems(InvoiceQuery $query): array public function getInvoiceItems(InvoiceQuery $query): array
{ {
$entries = []; $items = [];
$temp = $this->findInvoiceItemsWithRepository($query);
foreach ($temp as $repo => $items) { foreach ($this->getInvoiceItemRepositories() as $repository) {
$entries = array_merge($entries, $items); $items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
} }
return $entries; return $items;
} }
private function getDateTimeFactory(InvoiceQuery $query): DateTimeFactory private function getDateTimeFactory(InvoiceQuery $query): DateTimeFactory
@@ -316,29 +303,17 @@ final class ServiceInvoice
} }
/** /**
* @param array<string, InvoiceItemInterface[]> $entries * @param InvoiceItemInterface[] $entries
*/ */
private function markEntriesAsExported(iterable $entries) private function markEntriesAsExported(array $entries)
{ {
$repositories = $this->getInvoiceItemRepositories(); foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
foreach ($entries as $repo => $items) {
foreach ($repositories as $repository) {
if (\get_class($repository) === $repo) {
$repository->setExported($items);
}
}
} }
} }
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response public function renderInvoiceWithModel(InvoiceModel $model, EventDispatcherInterface $dispatcher): Response
{ {
$entries = $this->findInvoiceItemsWithRepository($query);
$model = $this->createModel($query);
foreach ($entries as $repo => $items) {
$model->addEntries($items);
}
$document = $this->getDocumentByName($model->getTemplate()->getRenderer()); $document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) { if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
@@ -361,20 +336,21 @@ final class ServiceInvoice
); );
} }
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response
{
$model = $this->createModel($query);
return $this->renderInvoiceWithModel($model, $dispatcher);
}
/** /**
* @param InvoiceQuery $query * @param InvoiceModel $model
* @param EventDispatcherInterface $dispatcher * @param EventDispatcherInterface $dispatcher
* @return Invoice * @return Invoice
* @throws \Exception * @throws \Exception
*/ */
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice public function createInvoiceFromModel(InvoiceModel $model, EventDispatcherInterface $dispatcher): Invoice
{ {
$entries = $this->findInvoiceItemsWithRepository($query);
$model = $this->createModel($query);
foreach ($entries as $repo => $items) {
$model->addEntries($items);
}
$document = $this->getDocumentByName($model->getTemplate()->getRenderer()); $document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) { if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
@@ -386,8 +362,8 @@ final class ServiceInvoice
$response = $renderer->render($document, $model); $response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) { if ($model->getQuery()->isMarkAsExported()) {
$this->markEntriesAsExported($entries); $this->markEntriesAsExported($model->getEntries());
} }
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response); $event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
@@ -411,6 +387,37 @@ final class ServiceInvoice
); );
} }
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice[]
* @throws \Exception
*/
public function createInvoices(InvoiceQuery $query, EventDispatcherInterface $dispatcher): array
{
$invoices = [];
$models = $this->createModels($query);
foreach ($models as $model) {
$invoices[] = $this->createInvoiceFromModel($model, $dispatcher);
}
return $invoices;
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice
{
$model = $this->createModel($query);
return $this->createInvoiceFromModel($model, $dispatcher);
}
public function deleteInvoice(Invoice $invoice) public function deleteInvoice(Invoice $invoice)
{ {
$invoiceDirectory = $this->getInvoicesDirectory(); $invoiceDirectory = $this->getInvoicesDirectory();
@@ -426,9 +433,23 @@ final class ServiceInvoice
* @throws \Exception * @throws \Exception
*/ */
public function createModel(InvoiceQuery $query): InvoiceModel public function createModel(InvoiceQuery $query): InvoiceModel
{
$model = $this->createModelWithoutEntries($query);
$model->addEntries($this->getInvoiceItems($query));
$this->prepareModelQueryDates($model);
return $model;
}
private function createModelWithoutEntries(InvoiceQuery $query): InvoiceModel
{ {
$template = $query->getTemplate(); $template = $query->getTemplate();
if (!$query->hasCustomers()) {
throw new \Exception('Cannot create invoice model without customer');
}
if (null === $template) { if (null === $template) {
throw new \Exception('Cannot create invoice model without template'); throw new \Exception('Cannot create invoice model without template');
} }
@@ -449,9 +470,7 @@ final class ServiceInvoice
$model->setUser($query->getCurrentUser()); $model->setUser($query->getCurrentUser());
} }
if ($query->hasCustomers()) { $model->setCustomer($query->getCustomers()[0]);
$model->setCustomer($query->getCustomers()[0]);
}
$generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator()); $generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) { if (null === $generator) {
@@ -468,4 +487,95 @@ final class ServiceInvoice
return $model; return $model;
} }
private function prepareModelQueryDates(InvoiceModel $model)
{
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
if ($begin !== null && $end !== null) {
return;
}
if (\count($model->getEntries()) === 0) {
return;
}
$tmpBegin = null;
$tmpEnd = null;
foreach ($model->getEntries() as $entry) {
if ($begin === null) {
if ($tmpBegin === null) {
$tmpBegin = $entry->getBegin();
} else {
$tmpBegin = min($entry->getBegin(), $tmpBegin);
}
}
if ($end === null) {
if ($tmpEnd === null) {
$tmpEnd = $entry->getEnd();
} else {
$tmpEnd = max($entry->getEnd(), $tmpEnd);
}
}
}
if ($begin === null && $tmpBegin !== null) {
$model->getQuery()->setBegin($tmpBegin);
}
if ($end === null && $tmpEnd !== null) {
$model->getQuery()->setEnd($tmpEnd);
}
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel[]
* @throws \Exception
*/
public function createModels(InvoiceQuery $query): array
{
$models = [];
$customerEntries = [];
$items = $this->getInvoiceItems($query);
foreach ($items as $entry) {
$customer = $entry->getProject()->getCustomer();
$id = $customer->getId();
if (!\array_key_exists($id, $customerEntries)) {
$customerEntries[$id] = [
'customer' => $customer,
'entries' => [],
];
}
$customerEntries[$id]['entries'][] = $entry;
}
if (empty($customerEntries)) {
return [];
}
uasort($customerEntries, function ($a, $b) {
return strcmp($a['customer']->getName(), $b['customer']->getName());
});
foreach ($customerEntries as $id => $settings) {
if (empty($settings['entries'])) {
continue;
}
$customerQuery = clone $query;
$customerQuery->setCustomers([$settings['customer']]);
$model = $this->createModelWithoutEntries($customerQuery);
$model->addEntries($settings['entries']);
$this->prepareModelQueryDates($model);
$models[] = $model;
}
return $models;
}
} }

View File

@@ -32,7 +32,7 @@ final class TimesheetInvoiceItemRepository implements InvoiceItemRepositoryInter
*/ */
public function getInvoiceItemsForQuery(InvoiceQuery $query): iterable public function getInvoiceItemsForQuery(InvoiceQuery $query): iterable
{ {
return $this->repository->getTimesheetsForQuery($query); return $this->repository->getTimesheetsForQuery($query, true);
} }
/** /**

View File

@@ -27,6 +27,7 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery; use App\Repository\Query\TimesheetQuery;
use DateInterval; use DateInterval;
use DateTime; use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery; use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\Query\Expr\Join;
@@ -34,7 +35,6 @@ use Doctrine\ORM\QueryBuilder;
use Exception; use Exception;
use InvalidArgumentException; use InvalidArgumentException;
use Pagerfanta\Pagerfanta; use Pagerfanta\Pagerfanta;
use PDO;
/** /**
* @extends \Doctrine\ORM\EntityRepository<Timesheet> * @extends \Doctrine\ORM\EntityRepository<Timesheet>
@@ -837,15 +837,15 @@ class TimesheetRepository extends EntityRepository
} }
if ($query->isExported()) { if ($query->isExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, PDO::PARAM_BOOL); $qb->andWhere('t.exported = :exported')->setParameter('exported', true, Types::BOOLEAN);
} elseif ($query->isNotExported()) { } elseif ($query->isNotExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, PDO::PARAM_BOOL); $qb->andWhere('t.exported = :exported')->setParameter('exported', false, Types::BOOLEAN);
} }
if ($query->isBillable()) { if ($query->isBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, PDO::PARAM_BOOL); $qb->andWhere('t.billable = :billable')->setParameter('billable', true, Types::BOOLEAN);
} elseif ($query->isNotBillable()) { } elseif ($query->isNotBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, PDO::PARAM_BOOL); $qb->andWhere('t.billable = :billable')->setParameter('billable', false, Types::BOOLEAN);
} }
if (null !== $query->getModifiedAfter()) { if (null !== $query->getModifiedAfter()) {
@@ -942,7 +942,7 @@ class TimesheetRepository extends EntityRepository
->groupBy('a.id', 'p.id') ->groupBy('a.id', 'p.id')
->orderBy('maxid', 'DESC') ->orderBy('maxid', 'DESC')
->setMaxResults($limit) ->setMaxResults($limit)
->setParameter('visible', true, PDO::PARAM_BOOL) ->setParameter('visible', true, Types::BOOLEAN)
; ;
if (null !== $user) { if (null !== $user) {
@@ -974,7 +974,7 @@ class TimesheetRepository extends EntityRepository
} }
/** /**
* @param Timesheet[] $timesheets * @param Timesheet[]|int[] $timesheets
*/ */
public function setExported(array $timesheets) public function setExported(array $timesheets)
{ {
@@ -987,7 +987,7 @@ class TimesheetRepository extends EntityRepository
->update(Timesheet::class, 't') ->update(Timesheet::class, 't')
->set('t.exported', ':exported') ->set('t.exported', ':exported')
->where($qb->expr()->in('t.id', ':ids')) ->where($qb->expr()->in('t.id', ':ids'))
->setParameter('exported', true, PDO::PARAM_BOOL) ->setParameter('exported', true, Types::BOOLEAN)
->setParameter('ids', $timesheets) ->setParameter('ids', $timesheets)
->getQuery() ->getQuery()
->execute(); ->execute();

View File

@@ -1,18 +1,18 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %} {% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "invoice/actions.html.twig" as actions %} {% import "invoice/actions.html.twig" as actions %}
{% set columns = { {% set columns = {
'date': {'class': 'alwaysVisible', 'orderBy': false}, 'date': {'class': 'alwaysVisible w-min', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, 'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'description': {'class': 'hidden-xs hidden-sm', 'orderBy': false}, 'description': {'class': 'hidden-xs hidden-sm hidden', 'orderBy': false},
'unit_price': {'class': 'hidden-xs text-center', 'orderBy': false}, 'user': {'class': 'hidden-xs hidden-sm w-min', 'orderBy': false},
'amount': {'class': 'text-center', 'orderBy': false}, 'unit_price': {'class': 'hidden-xs text-center w-min', 'orderBy': false},
'duration': {'class': 'hidden-xs text-center', 'orderBy': false}, 'amount': {'class': 'text-center w-min', 'orderBy': false},
'total_rate': {'class': 'text-right alwaysVisible', 'orderBy': false}, 'duration': {'class': 'hidden-xs text-center w-min', 'orderBy': false},
'total_rate': {'class': 'text-right alwaysVisible w-min', 'orderBy': false},
} %} } %}
{% set tableName = 'invoice' %} {% set tableName = 'invoice' %}
@@ -28,6 +28,7 @@
{% if is_granted('create_invoice') %} {% if is_granted('create_invoice') %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %} {% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/search.html.twig" as search %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %} {% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'invoice.filter'|trans }}{% endblock %} {% block box_title %}{{ 'invoice.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %} {% block box_before %}{{ form_start(form) }}{% endblock %}
@@ -37,7 +38,7 @@
{{ form_row(form.searchTerm) }} {{ form_row(form.searchTerm) }}
{% endif %} {% endif %}
{{ form_row(form.daterange) }} {{ form_row(form.daterange) }}
{{ form_row(form.customer) }} {{ form_row(form.customers) }}
{{ form_row(form.projects) }} {{ form_row(form.projects) }}
{% if form.activities is defined %} {% if form.activities is defined %}
{{ form_row(form.activities) }} {{ form_row(form.activities) }}
@@ -55,13 +56,7 @@
{{ form_row(form.markAsExported) }} {{ form_row(form.markAsExported) }}
{% endblock %} {% endblock %}
{% block box_footer%} {% block box_footer%}
{% set createAttr = {'class': 'btn btn-success'} %} {{ search.searchButton(form) }}
{% if not is_granted('history_invoice') %}
{% set createAttr = createAttr|merge({'formtarget': '_blank'}) %}
{% endif %}
{{ form_widget(form.create, {'attr': createAttr}) }}
{{ form_widget(form.print) }}
{{ form_widget(form.preview) }}
{% endblock %} {% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %} {% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %} {% endembed %}
@@ -69,66 +64,177 @@
{{ widgets.callout('danger', 'http_error_403.suggestion'|trans({}, 'exceptions')) }} {{ widgets.callout('danger', 'http_error_403.suggestion'|trans({}, 'exceptions')) }}
{% endif %} {% endif %}
{% if model is not null %} {% if searched %}
{% if model.calculator is empty or model.calculator.entries is empty %} {% set showEmpty = true %}
{% for model in models %}
{% if showEmpty %}
{% set showEmpty = model.calculator is empty or model.calculator.entries is empty %}
{% endif %}
{% endfor %}
{% if showEmpty %}
{{ widgets.nothing_found() }} {{ widgets.nothing_found() }}
{% else %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set entries = model.calculator.entries %}
{% set currency = model.currency %}
{{ tables.datatable_header(tableName, columns, query, {}) }}
{% for entry in entries %}
{% set amount = entry.amount %}
{% set duration = entry.duration|duration(isDecimal) %}
{% set rate = 0 %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% 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, 'project') }}">
{{ widgets.label_project(entry.project) }}
{% if entry.activity is not null %}
<br>
<small>
{{ widgets.label_activity(entry.activity) }}
</small>
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }} text-center">{{ rate|money(currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }} text-center text-nowrap">{{ amount }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-center text-nowrap" data-duration="{{ entry.duration }}">{{ duration }}</td>
<td class="text-right text-nowrap">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
<tr>
<th></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}"></th>
<th class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-center text-nowrap">{{ model.calculator.timeWorked|duration(isDecimal) }}</th>
<th class="text-right text-nowrap">{{ model.calculator.total|money(currency) }}</th>
</tr>
{{ tables.data_table_footer(entries) }}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if models|length > 0 %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import '@AdminLTE/Macros/buttons.html.twig' as button %}
{% block box_title %}
{{ 'button.preview'|trans }}: {{ 'invoice.title'|trans }}
{% endblock %}
{% block box_body_class %}no-padding{% endblock %}
{% block box_footer %}
<a href="#" onclick="return saveAllInvoices(this);" class="btn btn-primary" id="create_all_invoices">
{{ 'action.save_all'|trans }}
</a>
{% endblock %}
{% block box_body %}
<table class="table table-striped table-hover dataTable">
<thead>
<tr>
<th>{{ 'label.customer'|trans }}</th>
<th class="w-min text-center actions"></th>
<th class="w-min text-center hidden-xs">{{ 'label.duration'|trans }}</th>
<th class="w-min text-right hidden-xs">{{ 'label.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for model in models %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set currency = model.currency %}
<tr>
<td>
{{ widgets.label_customer(model.customer) }}
</td>
<td class="w-min text-center">
{{ widgets.action_button('show', {'url': '#invoice_preview_details_' ~ model.customer.id, 'title': 'timesheet.all'|trans, 'class': 'btn btn-sm hidden-xs hidden-sm'}, 'link') }}
{{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }}
{{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'primary') }}
</td>
<td class="w-min text-center hidden-xs">
{{ model.calculator.timeWorked|duration(isDecimal) }}
</td>
<td class="w-min text-right hidden-xs">
<span title="{{ 'label.vat'|trans }}: {{ model.calculator.tax|money(currency) }}" data-toggle="tooltip">{{ model.calculator.total|money(currency) }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% endembed %}
{% for model in models %}
{% set isEmptyModel = model.calculator is empty or model.calculator.entries is empty %}
{% if not isEmptyModel %}
{% set customer = model.query.customers[0] %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set entries = model.calculator.entries %}
{% set currency = model.currency %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/datatables.html.twig" as tables %}
{% block box_title %}
<span id="invoice_preview_details_{{ customer.id }}">{{ widgets.label_customer(customer) }}</span>
<small>{{ 'label.duration'|trans }}: {{ model.calculator.timeWorked|duration() }}, {{ 'label.total_rate'|trans }}: {{ model.calculator.total|money(currency) }}</small>
{% endblock %}
{% block box_body_class %}invoice-preview-box no-padding{% endblock %}
{% block box_body %}
{{ tables.datatable_header(tableName, columns, model.query, {'boxClass': ''}) }}
{% set itemsAmount = entries|length %}
{% if limit_preview %}
{% set entries = entries|slice(0, 100) %}
{% endif %}
{% for entry in entries %}
{% set amount = entry.amount %}
{% set duration = entry.duration|duration(isDecimal) %}
{% set rate = 0 %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr>
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.begin|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}">
{{ widgets.label_project(entry.project) }}
{% if entry.activity is not null %}
<br>
<small>
{{ widgets.label_activity(entry.activity) }}
</small>
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% endif %}
</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, 'unit_price') }}">{{ rate|money(currency) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}">{{ amount }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}" data-duration="{{ entry.duration }}">{{ duration }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
{% if limit_preview and itemsAmount > 100 %}
<tr class="warning">
<td colspan="8">&raquo; {{ 'preview.skipped_rows'|trans({'%rows%': (itemsAmount - 100)}) }}</td>
</tr>
{% endif %}
<tr class="summary">
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}"></td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ model.calculator.timeWorked|duration(isDecimal) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">{{ model.calculator.total|money(currency) }}</td>
</tr>
{{ tables.data_table_footer(entries) }}
{% endblock %}
{% endembed %}
{% endif %}
{% endfor %}
{% endif %}
{% endblock %} {% endblock %}
{% block javascripts %} {% block javascripts %}
{{ parent() }} {{ parent() }}
{% set formId = form.vars.attr.id %}
{% set formSelector = 'form#' ~ formId %}
<script type="text/javascript"> <script type="text/javascript">
function singleInvoice(link)
{
let formPlugin = kimai.getPlugin('form');
let data = formPlugin.getFormData(document.getElementById('{{ formId }}'));
let uri = formPlugin.convertFormDataToQueryString(data);
let baseUrl = link.getAttribute('data-href');
link.href = baseUrl + '?' + uri;
return true;
}
function saveAllInvoices(link)
{
let formPlugin = kimai.getPlugin('form');
let data = formPlugin.getFormData(document.getElementById('{{ formId }}'));
let uri = formPlugin.convertFormDataToQueryString(data);
link.href = '{{ path('invoice') }}?createInvoice=true&' + uri;
return true;
}
document.addEventListener('kimai.initialized', function() { document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true); KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -6,14 +6,14 @@
{% import "invoice/macros.html.twig" as macros %} {% import "invoice/macros.html.twig" as macros %}
{% set columns = { {% set columns = {
'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false}, 'date': {'class': 'alwaysVisible w-min', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false}, 'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false},
'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false}, 'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false},
'invoice_number': {'class': 'hidden-xs hidden-sm text-nowrap', 'title': 'invoice.number'|trans, 'orderBy': false}, 'invoice_number': {'class': 'hidden-xs hidden-sm text-center w-min', 'title': 'invoice.number'|trans, 'orderBy': false},
'due_date': {'class': 'hidden-xs text-nowrap', 'title': 'invoice.due_days'|trans, 'orderBy': false}, 'due_date': {'class': 'hidden-xs text-center w-min', 'title': 'invoice.due_days'|trans, 'orderBy': false},
'status': {'class': 'text-center alwaysVisible text-nowrap', 'orderBy': false}, 'status': {'class': 'text-center alwaysVisible w-min', 'orderBy': false},
'tax': {'class': 'hidden-xs text-center text-nowrap hidden', 'title': 'invoice.tax'|trans, 'orderBy': false}, 'tax': {'class': 'hidden-xs text-center w-min hidden', 'title': 'invoice.tax'|trans, 'orderBy': false},
'total_rate': {'class': 'hidden-xs text-center text-nowrap', 'orderBy': false}, 'total_rate': {'class': 'hidden-xs text-right w-min', 'orderBy': false},
'actions': {'class': 'actions alwaysVisible', 'orderBy': false}, 'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
} %} } %}

View File

@@ -53,11 +53,10 @@
{% set translationPrefix = options.translationPrefix ?? 'label.' %} {% set translationPrefix = options.translationPrefix ?? 'label.' %}
{% set boxClass = options.boxClass ?? 'box box-' ~ admin_lte_context.widget.type ~ ' data_table' %} {% set boxClass = options.boxClass ?? 'box box-' ~ admin_lte_context.widget.type ~ ' data_table' %}
{% import _self as macro %} <div class="{{ boxClass }} datatable_{{ tableName }}">
<div class="{{ boxClass }}" id="datatable_{{ tableName }}">
<div class="box-body no-padding"> <div class="box-body no-padding">
<div class="dataTables_wrapper form-inline dt-bootstrap"> <div class="dataTables_wrapper form-inline dt-bootstrap">
<table class="table {% if striped %}table-striped {% endif %}{% if bordered %}table-bordered {% endif %}table-hover dataTable" role="grid" data-reload-event="{{ reloadEvent }}" id="dt_{{ tableName }}"> <table class="table {% if striped %}table-striped {% endif %}{% if bordered %}table-bordered {% endif %}table-hover dataTable" role="grid" data-reload-event="{{ reloadEvent }}">
<thead> <thead>
<tr> <tr>
{%- for title, headerOptions in columns -%} {%- for title, headerOptions in columns -%}
@@ -71,7 +70,7 @@
{% set headerOptions = headerOptions|merge({'orderBy': title}) %} {% set headerOptions = headerOptions|merge({'orderBy': title}) %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set headerClass = macro.data_table_column_class(tableName, columns, title) %} {% set headerClass = _self.data_table_column_class(tableName, columns, title) %}
{% if title != 'actions' and not headerOptions.orderBy is same as(false) %} {% if title != 'actions' and not headerOptions.orderBy is same as(false) %}
{% if orderBy == headerOptions.orderBy %} {% if orderBy == headerOptions.orderBy %}
{% set headerClass = headerClass ~ ' sortable sorting_' ~ (order) %} {% set headerClass = headerClass ~ ' sortable sorting_' ~ (order) %}

View File

@@ -11,39 +11,25 @@
</h4> </h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{% set orderHasError = form.orderBy.vars.errors|length > 0 or form.order.vars.errors|length > 0 %}
{% set orderBy = form_widget(form.orderBy) %} {% set orderBy = form_widget(form.orderBy) %}
{% set order = form_widget(form.order) %} {% set order = form_widget(form.order) %}
{{ form(form) }} {{ form(form) }}
<div class="form-group"> <div class="form-group{% if orderHasError %} has-error{% endif %}">
{{ form_label(form.orderBy) }} {{ form_label(form.orderBy) }}
<div class="col-sm-5 col-xs-5"> <div class="col-sm-5 col-xs-5">
{{ orderBy|raw }} {{ orderBy|raw }}
{{ form_errors(form.orderBy) }}
</div> </div>
<div class="col-sm-4 col-xs-3"> <div class="col-sm-4 col-xs-3">
{{ order|raw }} {{ order|raw }}
{{ form_errors(form.order) }}
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="btn-toolbar pull-left" role="toolbar"> <div class="btn-toolbar pull-left" role="toolbar">
<div class="btn-group"> {{ _self.searchButton(form) }}
<button type="submit" name="performSearch" value="performSearch" class="btn btn-primary pull-left" data-type="submit">{{ 'search'|trans }}</button>
</div>
<div class="btn-group">
{% if form.vars.data.bookmark %}
<button type="submit" id="setDefaultQuery" name="setDefaultQuery" class="btn btn-default" title="{{ 'label.set_as_default'|trans }}"><i class="{{ 'bookmarked'|icon }}"></i></button>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li>
<a href="?removeDefaultQuery={{ form.vars.data.bookmark.name }}" id="removeDefaultQuery">{{ 'action.delete'|trans }}</a>
</li>
</ul>
{% else %}
<button type="submit" id="setDefaultQuery" name="setDefaultQuery" class="btn btn-default" title="{{ 'label.set_as_default'|trans }}"><i class="{{ 'bookmark'|icon }}"></i></button>
{% endif %}
</div>
</div> </div>
<button type="button" class="btn btn-default btn-cancel" data-dismiss="modal">{{ 'action.close'|trans }}</button> <button type="button" class="btn btn-default btn-cancel" data-dismiss="modal">{{ 'action.close'|trans }}</button>
</div> </div>
@@ -52,3 +38,24 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro searchButton(form) %}
<div class="btn-group">
<button type="submit" name="performSearch" value="performSearch" class="btn btn-primary pull-left" data-type="submit">{{ 'search'|trans }}</button>
</div>
<div class="btn-group">
{% if form.vars.data.bookmark %}
<button type="submit" id="setDefaultQuery" name="setDefaultQuery" class="btn btn-default" title="{{ 'label.set_as_default'|trans }}"><i class="{{ 'bookmarked'|icon }}"></i></button>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li>
<a href="?removeDefaultQuery={{ form.vars.data.bookmark.name }}" id="removeDefaultQuery">{{ 'action.delete'|trans }}</a>
</li>
</ul>
{% else %}
<button type="submit" id="setDefaultQuery" name="setDefaultQuery" class="btn btn-default" title="{{ 'label.set_as_default'|trans }}"><i class="{{ 'bookmark'|icon }}"></i></button>
{% endif %}
</div>
{% endmacro %}

View File

@@ -18,7 +18,6 @@ use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository; use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository; use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository; use App\Repository\UserRepository;
use App\Tests\DataFixtures\CustomerFixtures; use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\InvoiceTemplateFixtures; use App\Tests\DataFixtures\InvoiceTemplateFixtures;
@@ -69,7 +68,6 @@ class InvoiceCreateCommandTest extends KernelTestCase
$this->application->add(new InvoiceCreateCommand( $this->application->add(new InvoiceCreateCommand(
$container->get(ServiceInvoice::class), $container->get(ServiceInvoice::class),
$container->get(TimesheetRepository::class),
$container->get(CustomerRepository::class), $container->get(CustomerRepository::class),
$container->get(ProjectRepository::class), $container->get(ProjectRepository::class),
$container->get(InvoiceTemplateRepository::class), $container->get(InvoiceTemplateRepository::class),

View File

@@ -208,7 +208,7 @@ abstract class ControllerBaseTest extends WebTestCase
*/ */
protected function assertDataTableRowCount(HttpKernelBrowser $client, string $id, int $count) protected function assertDataTableRowCount(HttpKernelBrowser $client, string $id, int $count)
{ {
$node = $client->getCrawler()->filter('section.content div#' . $id . ' table.table-striped tbody tr:not(.summary)'); $node = $client->getCrawler()->filter('section.content div.' . $id . ' table.table-striped tbody tr:not(.summary)');
self::assertEquals($count, $node->count()); self::assertEquals($count, $node->count());
} }
@@ -346,7 +346,7 @@ abstract class ControllerBaseTest extends WebTestCase
* @param HttpKernelBrowser $client * @param HttpKernelBrowser $client
* @param string $url * @param string $url
*/ */
protected function assertIsRedirect(HttpKernelBrowser $client, $url = null) protected function assertIsRedirect(HttpKernelBrowser $client, $url = null, $endsWith = true)
{ {
self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect'); self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect');
if (null === $url) { if (null === $url) {
@@ -354,7 +354,19 @@ abstract class ControllerBaseTest extends WebTestCase
} }
self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header'); self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header');
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'), 'Redirect URL does not match'); if ($endsWith) {
self::assertStringEndsWith(
$url,
$client->getResponse()->headers->get('Location'),
'Redirect URL does not match'
);
} else {
self::assertStringContainsString(
$url,
$client->getResponse()->headers->get('Location'),
'Redirect URL does not match'
);
}
} }
protected function assertExcelExportResponse(HttpKernelBrowser $client, string $prefix) protected function assertExcelExportResponse(HttpKernelBrowser $client, string $prefix)

View File

@@ -73,7 +73,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$templates = $this->importFixture($fixture); $templates = $this->importFixture($fixture);
$id = $templates[0]->getId(); $id = $templates[0]->getId();
$this->request($client, '/invoice/?customer=1&template=' . $id . '&preview='); $this->request($client, '/invoice/?customers[]=1&template=' . $id);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasNoEntriesWithFilter($client); $this->assertHasNoEntriesWithFilter($client);
@@ -168,12 +168,12 @@ class InvoiceControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('#invoice-print-form')->form(); $form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode(); $node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview=')); $node->setAttribute('action', $this->createUrl('/invoice/'));
$node->setAttribute('method', 'GET'); $node->setAttribute('method', 'GET');
$client->submit($form, [ $client->submit($form, [
'template' => $template->getId(), 'template' => $template->getId(),
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => 1, 'customers' => [1],
]); ]);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
@@ -181,25 +181,20 @@ class InvoiceControllerTest extends ControllerBaseTest
// no warning should be displayed // no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count()); $this->assertEquals(0, $node->count());
// but the datatable with all timesheets + 1 row for the total // but the datatable with all timesheets
$this->assertDataTableRowCount($client, 'datatable_invoice', 21); $this->assertDataTableRowCount($client, 'datatable_invoice', 20);
$form = $client->getCrawler()->filter('#invoice-print-form')->form(); $urlParams = [
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?create='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $template->getId(),
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => 1, 'projects[]' => 1,
'projects' => [1],
'markAsExported' => 1, 'markAsExported' => 1,
]); ];
$this->assertTrue($client->getResponse()->isSuccessful()); $action = '/invoice/save-invoice/1/' . $template->getId() . '?' . http_build_query($urlParams);
$node = $client->getCrawler()->filter('body'); $this->request($client, $action);
$this->assertEquals(1, $node->count()); $this->assertIsRedirect($client, '/invoice/show?id=', false);
$this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class')); $client->followRedirect();
$this->assertDataTableRowCount($client, 'datatable_invoices', 1);
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$em->clear(); $em->clear();
@@ -234,35 +229,13 @@ class InvoiceControllerTest extends ControllerBaseTest
$dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d'); $dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d');
$form = $client->getCrawler()->filter('#invoice-print-form')->form(); $params = [
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $id,
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
// no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
// but the datatable with all timesheets + 1 row for the total
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?print='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $id,
'daterange' => $dateRange,
'customer' => 1,
'projects' => [1], 'projects' => [1],
]); ];
$action = '/invoice/preview/1/' . $id . '?' . http_build_query($params);
$this->request($client, $action);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('body'); $node = $client->getCrawler()->filter('body');
$this->assertEquals(1, $node->count()); $this->assertEquals(1, $node->count());
@@ -299,7 +272,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$client->submit($form, [ $client->submit($form, [
'template' => $template->getId(), 'template' => $template->getId(),
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => 1, 'customers' => [1],
]); ]);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
@@ -307,17 +280,17 @@ class InvoiceControllerTest extends ControllerBaseTest
// no warning should be displayed // no warning should be displayed
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count()); $this->assertEquals(0, $node->count());
// but the datatable with all timesheets + 1 row for the total // but the datatable with all timesheets
$this->assertDataTableRowCount($client, 'datatable_invoice', 21); $this->assertDataTableRowCount($client, 'datatable_invoice', 20);
$form = $client->getCrawler()->filter('#invoice-print-form')->form(); $form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode(); $node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?create=')); $node->setAttribute('action', $this->createUrl('/invoice/?createInvoice=true'));
$node->setAttribute('method', 'GET'); $node->setAttribute('method', 'GET');
$client->submit($form, [ $client->submit($form, [
'template' => $template->getId(), 'template' => $template->getId(),
'daterange' => $dateRange, 'daterange' => $dateRange,
'customer' => 1, 'customers' => [1],
'projects' => [1], 'projects' => [1],
'markAsExported' => 1, 'markAsExported' => 1,
]); ]);

View File

@@ -35,7 +35,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions'); $this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 119); $this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 118);
$this->assertPageActions($client, [ $this->assertPageActions($client, [
//'back' => $this->createUrl('/admin/user/'), //'back' => $this->createUrl('/admin/user/'),
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'), 'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),

View File

@@ -54,7 +54,7 @@ class ProjectViewControllerTest extends ControllerBaseTest
$this->assertAccessIsGranted($client, '/reporting/project_view'); $this->assertAccessIsGranted($client, '/reporting/project_view');
self::assertStringContainsString('<div class="box-body project-view-reporting-box', $client->getResponse()->getContent()); self::assertStringContainsString('<div class="box-body project-view-reporting-box', $client->getResponse()->getContent());
$rows = $client->getCrawler()->filterXPath("//table[@id='dt_project_view_reporting']/tbody/tr"); $rows = $client->getCrawler()->filterXPath("//table[contains(@class, 'dataTable')]/tbody/tr[not(@class='summary')]");
self::assertGreaterThan(0, $rows->count()); self::assertGreaterThan(0, $rows->count());
} }
} }

View File

@@ -82,7 +82,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_timesheet', 7); $this->assertDataTableRowCount($client, 'datatable_timesheet', 7);
// make sure the recording css class exist on tr for targeting running record rows // make sure the recording css class exist on tr for targeting running record rows
$node = $client->getCrawler()->filter('section.content div#datatable_timesheet table.table-striped tbody tr.recording'); $node = $client->getCrawler()->filter('section.content div.datatable_timesheet table.table-striped tbody tr.recording');
self::assertEquals(2, $node->count()); self::assertEquals(2, $node->count());
} }

View File

@@ -87,7 +87,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_timesheet_admin', 13); $this->assertDataTableRowCount($client, 'datatable_timesheet_admin', 13);
// make sure the recording css class exist on tr for targeting running record rows // make sure the recording css class exist on tr for targeting running record rows
$node = $client->getCrawler()->filter('section.content div#datatable_timesheet_admin table.table-striped tbody tr.recording'); $node = $client->getCrawler()->filter('section.content div.datatable_timesheet_admin table.table-striped tbody tr.recording');
self::assertEquals(3, $node->count()); self::assertEquals(3, $node->count());
} }

View File

@@ -10,10 +10,14 @@
namespace App\Tests\Invoice; namespace App\Tests\Invoice;
use App\Configuration\LanguageFormattings; use App\Configuration\LanguageFormattings;
use App\Entity\Customer;
use App\Entity\Invoice; use App\Entity\Invoice;
use App\Entity\InvoiceDocument; use App\Entity\InvoiceDocument;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer; use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
@@ -93,11 +97,8 @@ class ServiceInvoiceTest extends TestCase
$sut->addCalculator(new DefaultCalculator()); $sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator($this->getNumberGeneratorSut()); $sut->addNumberGenerator($this->getNumberGeneratorSut());
$sut->addRenderer( $twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
new TwigRenderer( $sut->addRenderer(new TwigRenderer($twig));
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock()
)
);
$this->assertEquals(1, \count($sut->getCalculator())); $this->assertEquals(1, \count($sut->getCalculator()));
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default')); $this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
@@ -113,8 +114,11 @@ class ServiceInvoiceTest extends TestCase
$this->expectException(\Exception::class); $this->expectException(\Exception::class);
$this->expectExceptionMessage('Cannot create invoice model without template'); $this->expectExceptionMessage('Cannot create invoice model without template');
$query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$sut = $this->getSut([]); $sut = $this->getSut([]);
$sut->createModel(new InvoiceQuery()); $sut->createModel($query);
} }
/** /**
@@ -124,10 +128,10 @@ class ServiceInvoiceTest extends TestCase
{ {
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
$template->setNumberGenerator('date'); $template->setNumberGenerator('date');
self::assertNull($template->getLanguage()); self::assertNull($template->getLanguage());
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$query->setTemplate($template); $query->setTemplate($template);
$sut = $this->getSut([]); $sut = $this->getSut([]);
@@ -139,6 +143,30 @@ class ServiceInvoiceTest extends TestCase
self::assertEquals('en', $model->getTemplate()->getLanguage()); self::assertEquals('en', $model->getTemplate()->getLanguage());
} }
/**
* @group legacy
*/
public function testFindInvoiceItemsWithoutCustomer()
{
$sut = $this->getSut([]);
$query = new InvoiceQuery();
$items = $sut->findInvoiceItems($query);
self::assertEquals([], $items);
}
public function testFindInvoiceItemsWithCustomer()
{
$sut = $this->getSut([]);
$query = new InvoiceQuery();
$query->setCustomers([new Customer(), new Customer()]);
$items = $sut->findInvoiceItems($query);
self::assertEquals([], $items);
}
public function testCreateModelUsesTemplateLanguage() public function testCreateModelUsesTemplateLanguage()
{ {
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
@@ -148,6 +176,7 @@ class ServiceInvoiceTest extends TestCase
self::assertEquals('de', $template->getLanguage()); self::assertEquals('de', $template->getLanguage());
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$query->setTemplate($template); $query->setTemplate($template);
$sut = $this->getSut([]); $sut = $this->getSut([]);
@@ -159,6 +188,74 @@ class ServiceInvoiceTest extends TestCase
self::assertEquals('de', $model->getTemplate()->getLanguage()); self::assertEquals('de', $model->getTemplate()->getLanguage());
} }
public function testBeginAndEndDateFallback()
{
$timezone = new \DateTimeZone('Europe/Vienna');
$customer = new Customer();
$project = new Project();
$project->setCustomer($customer);
$timesheet1 = new Timesheet();
$timesheet1->setProject($project);
$timesheet1->setBegin(new \DateTime('2011-01-27 12:12:12', $timezone));
$timesheet1->setEnd(new \DateTime('2020-01-27 12:12:12', $timezone));
$timesheet2 = new Timesheet();
$timesheet2->setProject($project);
$timesheet2->setBegin(new \DateTime('2010-01-27 08:24:33', $timezone));
$timesheet2->setEnd(new \DateTime('2019-01-27 12:12:12', $timezone));
$timesheet3 = new Timesheet();
$timesheet3->setProject($project);
$timesheet3->setBegin(new \DateTime('2019-01-27 12:12:12', $timezone));
$timesheet3->setEnd(new \DateTime('2020-01-07 12:12:12', $timezone));
$timesheet4 = new Timesheet();
$timesheet4->setProject($project);
$timesheet4->setBegin(new \DateTime('2020-01-27 10:12:12', $timezone));
$timesheet4->setEnd(new \DateTime('2020-11-27 11:12:12', $timezone));
$timesheet5 = new Timesheet();
$timesheet5->setProject($project);
$timesheet5->setBegin(new \DateTime('2012-01-27 12:12:12', $timezone));
$timesheet5->setEnd(new \DateTime('2018-01-27 12:12:12', $timezone));
$repo = $this->createMock(InvoiceItemRepositoryInterface::class);
$repo->method('getInvoiceItemsForQuery')->willReturn([
$timesheet1,
$timesheet2,
$timesheet3,
$timesheet4,
$timesheet5,
]);
$template = new InvoiceTemplate();
$template->setNumberGenerator('date');
$template->setLanguage('de');
self::assertEquals('de', $template->getLanguage());
$query = new InvoiceQuery();
$query->setCustomers([new Customer(), $customer]);
$query->setTemplate($template);
self::assertNull($query->getBegin());
self::assertNull($query->getEnd());
$sut = $this->getSut([]);
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator($this->getNumberGeneratorSut());
$sut->addInvoiceItemRepository($repo);
$sut->createModels($query);
self::assertNotNull($query->getBegin());
self::assertNotNull($query->getEnd());
self::assertEquals('2010-01-27T00:00:00+0100', $query->getBegin()->format(DATE_ISO8601));
self::assertEquals('2020-11-27T23:59:59+0100', $query->getEnd()->format(DATE_ISO8601));
}
private function getNumberGeneratorSut() private function getNumberGeneratorSut()
{ {
$repository = $this->createMock(InvoiceRepository::class); $repository = $this->createMock(InvoiceRepository::class);

View File

@@ -733,10 +733,6 @@
<source>invoice.title</source> <source>invoice.title</source>
<target>Faktura</target> <target>Faktura</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Toto je náhled dat, která se zobrazí ve vašem fakturačním dokladu.</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target>Tisk</target> <target>Tisk</target>

View File

@@ -391,6 +391,10 @@
<source>action.save</source> <source>action.save</source>
<target>Speichern</target> <target>Speichern</target>
</trans-unit> </trans-unit>
<trans-unit id="action.save_all">
<source>action.save_all</source>
<target>Alle Speichern</target>
</trans-unit>
<trans-unit id="action.reset"> <trans-unit id="action.reset">
<source>action.reset</source> <source>action.reset</source>
<target>Zurücksetzen</target> <target>Zurücksetzen</target>
@@ -1041,6 +1045,10 @@
<source>status.paid</source> <source>status.paid</source>
<target>Bezahlt</target> <target>Bezahlt</target>
</trans-unit> </trans-unit>
<trans-unit id="preview.skipped_rows">
<source>preview.skipped_rows</source>
<target>Überspringe Vorschau von %rows% weiteren Zeilen ...</target>
</trans-unit>
<!-- <!--
Export Export
--> -->

View File

@@ -395,6 +395,10 @@
<source>action.save</source> <source>action.save</source>
<target>Save</target> <target>Save</target>
</trans-unit> </trans-unit>
<trans-unit id="action.save_all">
<source>action.save_all</source>
<target>Save all</target>
</trans-unit>
<trans-unit id="action.reset"> <trans-unit id="action.reset">
<source>action.reset</source> <source>action.reset</source>
<target>Reset</target> <target>Reset</target>
@@ -1058,7 +1062,10 @@
<source>status.paid</source> <source>status.paid</source>
<target>Paid</target> <target>Paid</target>
</trans-unit> </trans-unit>
<trans-unit id="preview.skipped_rows">
<source>preview.skipped_rows</source>
<target>Skipped preview of %rows% more rows ...</target>
</trans-unit>
<!-- <!--
Export Export
--> -->

View File

@@ -693,10 +693,6 @@
<source>invoice.title</source> <source>invoice.title</source>
<target state="translated">인보이스</target> <target state="translated">인보이스</target>
</trans-unit> </trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target state="translated">인보이스에 표시되는 정보의 미리보기</target>
</trans-unit>
<trans-unit id="button.print"> <trans-unit id="button.print">
<source>button.print</source> <source>button.print</source>
<target state="translated">인쇄</target> <target state="translated">인쇄</target>