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.
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)
- 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) {
const table = document.getElementById('datatable_' + this.id).getElementsByClassName('dataTable')[0];
let column = 0;
let foundColumn = false;
for (let columnElement of table.getElementsByTagName('th')) {
if (columnElement.getAttribute('data-field') === columnName) {
foundColumn = true;
break;
const tables = document.getElementsByClassName('datatable_' + this.id);
for (let tableBox of tables) {
let column = 0;
let foundColumn = false;
let table = tableBox.getElementsByClassName('dataTable')[0];
for (let columnElement of table.getElementsByTagName('th')) {
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) {
console.log('Tables with colspans are not supported!');
if (!foundColumn) {
return;
}
column++;
}
for (let rowElement of table.getElementsByTagName('tr')) {
if (rowElement.children[column] === undefined) {
continue;
}
if (!foundColumn) {
return;
}
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');
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-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_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer']
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_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']

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"build/runtime.098eaae1.js",
"build/0.79dbdbb9.js",
"build/1.32489d92.js",
"build/app.b19b6399.js"
"build/app.0d1f3758.js"
],
"css": [
"build/app.b22111d1.css"
@@ -53,7 +53,7 @@
"build/runtime.098eaae1.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.79dbdbb9.js": "sha384-U2Ao0ORAZ8PCeDmyRsqQFET3hc7pfUBimq0PrqFdG4/s0Bdi+qBj4TJK3o70bCd5",
"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/invoice.74279541.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",

View File

@@ -3,7 +3,7 @@
"build/1.32489d92.js": "build/1.32489d92.js",
"build/2.7ab75d0a.js": "build/2.7ab75d0a.js",
"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.js": "build/calendar.070aab88.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\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use App\Utils\SearchTerm;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -39,10 +39,6 @@ class InvoiceCreateCommand extends Command
* @var ServiceInvoice
*/
private $serviceInvoice;
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/**
* @var CustomerRepository
*/
@@ -70,7 +66,6 @@ class InvoiceCreateCommand extends Command
public function __construct(
ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository,
ProjectRepository $projectRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
@@ -78,7 +73,6 @@ class InvoiceCreateCommand extends Command
EventDispatcherInterface $eventDispatcher
) {
$this->serviceInvoice = $serviceInvoice;
$this->timesheetRepository = $timesheetRepository;
$this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository;
$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('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('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('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')
@@ -157,7 +151,13 @@ class InvoiceCreateCommand extends Command
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'))) {
$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');
if (!empty($start)) {
try {
$start = new \DateTime($start, $timezone);
$start = $dateFactory->createDateTime($start);
} catch (\Exception $ex) {
$io->error('Invalid start date given');
@@ -199,14 +199,14 @@ class InvoiceCreateCommand extends Command
}
}
if (!$start instanceof \DateTime) {
$start = new \DateTime('first day of this month', $timezone);
$start = $dateFactory->getStartOfMonth();
}
$start->setTime(0, 0, 0);
$end = $input->getOption('end');
if (!empty($end)) {
try {
$end = new \DateTime($end, $timezone);
$end = $dateFactory->createDateTime($end);
} catch (\Exception $ex) {
$io->error('Invalid end date given');
@@ -214,7 +214,7 @@ class InvoiceCreateCommand extends Command
}
}
if (!$end instanceof \DateTime) {
$end = new \DateTime('last day of this month', $timezone);
$end = $dateFactory->getEndOfMonth();
}
$end->setTime(23, 59, 59);
@@ -519,7 +519,7 @@ class InvoiceCreateCommand extends Command
*/
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
{
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery);
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$customers = [];
@@ -537,7 +537,7 @@ class InvoiceCreateCommand extends Command
*/
private function getActiveProjects(InvoiceQuery $invoiceQuery): array
{
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery);
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$projects = [];

View File

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

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Export\Spreadsheet\AnnotatedObjectExporter;
@@ -30,7 +31,6 @@ use App\Repository\Query\InvoiceQuery;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -83,45 +83,30 @@ final class InvoiceController extends AbstractController
$this->flashWarning('invoice.first_template');
}
$model = null;
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->setData($query);
$form->submit($request->query->all(), false);
if ($this->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
if ($this->isGranted('create_invoice') && $form->isValid()) {
// 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());
}
$models = [];
$total = 0;
$searched = false;
try {
/** @var SubmitButton $createButton */
$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()) {
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($request->query->has('createInvoice')) {
try {
$model = $this->service->createModel($query);
$entries = $this->service->findInvoiceItems($query);
if (!empty($entries)) {
$model->addEntries($entries);
}
return $this->renderInvoice($query, $request);
} catch (Exception $ex) {
$this->logException($ex);
$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) {
$this->logException($ex);
$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', [
'query' => $query,
'model' => $model,
'models' => $models,
'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();
$begin = $factory->getStartOfMonth();
@@ -159,20 +203,23 @@ final class InvoiceController extends AbstractController
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 {
$invoice = $this->service->createInvoice($query, $this->dispatcher);
$invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if ($this->isGranted('history_invoice')) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
}
$file = $this->service->getInvoiceFile($invoice);
return $this->file($file->getRealPath(), $file->getBasename());
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $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"})
* @Security("is_granted('history_invoice')")
*/
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"})
* @Security("is_granted('history_invoice')")
*/
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"})
* @Security("is_granted('history_invoice')")
*/
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"})
* @Security("is_granted('history_invoice')")
*/
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
{
if (!$this->templateRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
$template = new InvoiceTemplate();
if (null !== $copyFrom) {
@@ -410,7 +449,7 @@ final class InvoiceController extends AbstractController
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);
@@ -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;

View File

@@ -30,15 +30,13 @@ class InvoiceSubscriber extends AbstractActionsSubscriber
return;
}
if ($this->isGranted('history_invoice')) {
if ($invoice->isNew()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]);
} elseif ($invoice->isPending()) {
$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);
if ($invoice->isNew()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]);
} elseif ($invoice->isPending()) {
$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);
}
}

View File

@@ -22,9 +22,7 @@ class InvoicesSubscriber extends AbstractActionsSubscriber
{
$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')) {
$event->addAction('invoice-template', ['url' => $this->path('admin_invoice_template')]);

View File

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

View File

@@ -12,7 +12,6 @@ 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;
@@ -28,22 +27,12 @@ class InvoiceToolbarSimpleForm extends AbstractToolbarForm
{
$this->addTemplateChoice($builder);
$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);
$builder->add('markAsExported', CheckboxType::class, [
'label' => 'label.mark_as_exported',
'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)

View File

@@ -21,21 +21,9 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$entries = $model->getCalculator()->getEntries();
$begin = null;
if ($model->getQuery()->getBegin() !== null) {
$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];
}
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),

View File

@@ -90,7 +90,9 @@ final class ServiceInvoice
{
foreach ($this->getNumberGenerator() as $generator) {
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) {
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
* @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
if (!$query->hasCustomers()) {
return [];
}
$factory = $this->getDateTimeFactory($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;
return $this->getInvoiceItems($query);
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
public function findInvoiceItems(InvoiceQuery $query): array
public function getInvoiceItems(InvoiceQuery $query): array
{
$entries = [];
$temp = $this->findInvoiceItemsWithRepository($query);
$items = [];
foreach ($temp as $repo => $items) {
$entries = array_merge($entries, $items);
foreach ($this->getInvoiceItemRepositories() as $repository) {
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
}
return $entries;
return $items;
}
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 ($entries as $repo => $items) {
foreach ($repositories as $repository) {
if (\get_class($repository) === $repo) {
$repository->setExported($items);
}
}
foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
}
}
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());
if (null === $document) {
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
* @return Invoice
* @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());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
@@ -386,8 +362,8 @@ final class ServiceInvoice
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
if ($model->getQuery()->isMarkAsExported()) {
$this->markEntriesAsExported($model->getEntries());
}
$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)
{
$invoiceDirectory = $this->getInvoicesDirectory();
@@ -426,9 +433,23 @@ final class ServiceInvoice
* @throws \Exception
*/
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();
if (!$query->hasCustomers()) {
throw new \Exception('Cannot create invoice model without customer');
}
if (null === $template) {
throw new \Exception('Cannot create invoice model without template');
}
@@ -449,9 +470,7 @@ final class ServiceInvoice
$model->setUser($query->getCurrentUser());
}
if ($query->hasCustomers()) {
$model->setCustomer($query->getCustomers()[0]);
}
$model->setCustomer($query->getCustomers()[0]);
$generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
@@ -468,4 +487,95 @@ final class ServiceInvoice
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
{
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 DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
@@ -34,7 +35,6 @@ use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
use Pagerfanta\Pagerfanta;
use PDO;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
@@ -837,15 +837,15 @@ class TimesheetRepository extends EntityRepository
}
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()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, PDO::PARAM_BOOL);
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, Types::BOOLEAN);
}
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()) {
$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()) {
@@ -942,7 +942,7 @@ class TimesheetRepository extends EntityRepository
->groupBy('a.id', 'p.id')
->orderBy('maxid', 'DESC')
->setMaxResults($limit)
->setParameter('visible', true, PDO::PARAM_BOOL)
->setParameter('visible', true, Types::BOOLEAN)
;
if (null !== $user) {
@@ -974,7 +974,7 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param Timesheet[] $timesheets
* @param Timesheet[]|int[] $timesheets
*/
public function setExported(array $timesheets)
{
@@ -987,7 +987,7 @@ class TimesheetRepository extends EntityRepository
->update(Timesheet::class, 't')
->set('t.exported', ':exported')
->where($qb->expr()->in('t.id', ':ids'))
->setParameter('exported', true, PDO::PARAM_BOOL)
->setParameter('exported', true, Types::BOOLEAN)
->setParameter('ids', $timesheets)
->getQuery()
->execute();

View File

@@ -1,18 +1,18 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "macros/datatables.html.twig" as tables %}
{% import "macros/toolbar.html.twig" as toolbar %}
{% import "invoice/actions.html.twig" as actions %}
{% set columns = {
'date': {'class': 'alwaysVisible', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'date': {'class': 'alwaysVisible w-min', 'orderBy': false},
'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'description': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
'unit_price': {'class': 'hidden-xs text-center', 'orderBy': false},
'amount': {'class': 'text-center', 'orderBy': false},
'duration': {'class': 'hidden-xs text-center', 'orderBy': false},
'total_rate': {'class': 'text-right alwaysVisible', 'orderBy': false},
'description': {'class': 'hidden-xs hidden-sm hidden', 'orderBy': false},
'user': {'class': 'hidden-xs hidden-sm w-min', 'orderBy': false},
'unit_price': {'class': 'hidden-xs text-center w-min', 'orderBy': false},
'amount': {'class': 'text-center w-min', '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' %}
@@ -28,6 +28,7 @@
{% if is_granted('create_invoice') %}
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
{% import "macros/search.html.twig" as search %}
{% form_theme form '@AdminLTE/layout/form-theme-horizontal.html.twig' %}
{% block box_title %}{{ 'invoice.filter'|trans }}{% endblock %}
{% block box_before %}{{ form_start(form) }}{% endblock %}
@@ -37,7 +38,7 @@
{{ form_row(form.searchTerm) }}
{% endif %}
{{ form_row(form.daterange) }}
{{ form_row(form.customer) }}
{{ form_row(form.customers) }}
{{ form_row(form.projects) }}
{% if form.activities is defined %}
{{ form_row(form.activities) }}
@@ -55,13 +56,7 @@
{{ form_row(form.markAsExported) }}
{% endblock %}
{% block box_footer%}
{% set createAttr = {'class': 'btn btn-success'} %}
{% 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) }}
{{ search.searchButton(form) }}
{% endblock %}
{% block box_after %}{{ form_end(form) }}{% endblock %}
{% endembed %}
@@ -69,66 +64,177 @@
{{ widgets.callout('danger', 'http_error_403.suggestion'|trans({}, 'exceptions')) }}
{% endif %}
{% if model is not null %}
{% if model.calculator is empty or model.calculator.entries is empty %}
{% if searched %}
{% 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() }}
{% 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 %}
{% 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 %}
{% block javascripts %}
{{ parent() }}
{% set formId = form.vars.attr.id %}
{% set formSelector = 'form#' ~ formId %}
<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() {
KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true);
});
</script>
{% endblock %}

View File

@@ -6,14 +6,14 @@
{% import "invoice/macros.html.twig" as macros %}
{% 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},
'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},
'due_date': {'class': 'hidden-xs text-nowrap', 'title': 'invoice.due_days'|trans, 'orderBy': false},
'status': {'class': 'text-center alwaysVisible text-nowrap', 'orderBy': false},
'tax': {'class': 'hidden-xs text-center text-nowrap hidden', 'title': 'invoice.tax'|trans, 'orderBy': false},
'total_rate': {'class': 'hidden-xs text-center text-nowrap', '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-center w-min', 'title': 'invoice.due_days'|trans, 'orderBy': false},
'status': {'class': 'text-center alwaysVisible w-min', 'orderBy': false},
'tax': {'class': 'hidden-xs text-center w-min hidden', 'title': 'invoice.tax'|trans, 'orderBy': false},
'total_rate': {'class': 'hidden-xs text-right w-min', 'orderBy': false},
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
} %}

View File

@@ -53,11 +53,10 @@
{% set translationPrefix = options.translationPrefix ?? 'label.' %}
{% set boxClass = options.boxClass ?? 'box box-' ~ admin_lte_context.widget.type ~ ' data_table' %}
{% import _self as macro %}
<div class="{{ boxClass }}" id="datatable_{{ tableName }}">
<div class="{{ boxClass }} datatable_{{ tableName }}">
<div class="box-body no-padding">
<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>
<tr>
{%- for title, headerOptions in columns -%}
@@ -71,7 +70,7 @@
{% set headerOptions = headerOptions|merge({'orderBy': title}) %}
{% 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 orderBy == headerOptions.orderBy %}
{% set headerClass = headerClass ~ ' sortable sorting_' ~ (order) %}

View File

@@ -11,39 +11,25 @@
</h4>
</div>
<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 order = form_widget(form.order) %}
{{ form(form) }}
<div class="form-group">
<div class="form-group{% if orderHasError %} has-error{% endif %}">
{{ form_label(form.orderBy) }}
<div class="col-sm-5 col-xs-5">
{{ orderBy|raw }}
{{ form_errors(form.orderBy) }}
</div>
<div class="col-sm-4 col-xs-3">
{{ order|raw }}
{{ form_errors(form.order) }}
</div>
</div>
</div>
<div class="modal-footer">
<div class="btn-toolbar pull-left" role="toolbar">
<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>
{{ _self.searchButton(form) }}
</div>
<button type="button" class="btn btn-default btn-cancel" data-dismiss="modal">{{ 'action.close'|trans }}</button>
</div>
@@ -52,3 +38,24 @@
</div>
</div>
{% 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\InvoiceTemplateRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Tests\DataFixtures\CustomerFixtures;
use App\Tests\DataFixtures\InvoiceTemplateFixtures;
@@ -69,7 +68,6 @@ class InvoiceCreateCommandTest extends KernelTestCase
$this->application->add(new InvoiceCreateCommand(
$container->get(ServiceInvoice::class),
$container->get(TimesheetRepository::class),
$container->get(CustomerRepository::class),
$container->get(ProjectRepository::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)
{
$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());
}
@@ -346,7 +346,7 @@ abstract class ControllerBaseTest extends WebTestCase
* @param HttpKernelBrowser $client
* @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');
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::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)

View File

@@ -73,7 +73,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$templates = $this->importFixture($fixture);
$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->assertHasNoEntriesWithFilter($client);
@@ -168,12 +168,12 @@ class InvoiceControllerTest extends ControllerBaseTest
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$node = $form->getFormNode();
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
$node->setAttribute('action', $this->createUrl('/invoice/'));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $template->getId(),
'daterange' => $dateRange,
'customer' => 1,
'customers' => [1],
]);
$this->assertTrue($client->getResponse()->isSuccessful());
@@ -181,25 +181,20 @@ class InvoiceControllerTest extends ControllerBaseTest
// 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);
// 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/?create='));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $template->getId(),
$urlParams = [
'daterange' => $dateRange,
'customer' => 1,
'projects' => [1],
'projects[]' => 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'));
$action = '/invoice/save-invoice/1/' . $template->getId() . '?' . http_build_query($urlParams);
$this->request($client, $action);
$this->assertIsRedirect($client, '/invoice/show?id=', false);
$client->followRedirect();
$this->assertDataTableRowCount($client, 'datatable_invoices', 1);
$em = $this->getEntityManager();
$em->clear();
@@ -234,35 +229,13 @@ 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' => $id,
$params = [
'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],
]);
];
$action = '/invoice/preview/1/' . $id . '?' . http_build_query($params);
$this->request($client, $action);
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('body');
$this->assertEquals(1, $node->count());
@@ -299,7 +272,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$client->submit($form, [
'template' => $template->getId(),
'daterange' => $dateRange,
'customer' => 1,
'customers' => [1],
]);
$this->assertTrue($client->getResponse()->isSuccessful());
@@ -307,17 +280,17 @@ class InvoiceControllerTest extends ControllerBaseTest
// 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);
// 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/?create='));
$node->setAttribute('action', $this->createUrl('/invoice/?createInvoice=true'));
$node->setAttribute('method', 'GET');
$client->submit($form, [
'template' => $template->getId(),
'daterange' => $dateRange,
'customer' => 1,
'customers' => [1],
'projects' => [1],
'markAsExported' => 1,
]);

View File

@@ -35,7 +35,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 119);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 118);
$this->assertPageActions($client, [
//'back' => $this->createUrl('/admin/user/'),
'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');
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());
}
}

View File

@@ -82,7 +82,7 @@ class TimesheetControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_timesheet', 7);
// 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());
}

View File

@@ -87,7 +87,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
$this->assertDataTableRowCount($client, 'datatable_timesheet_admin', 13);
// 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());
}

View File

@@ -10,10 +10,14 @@
namespace App\Tests\Invoice;
use App\Configuration\LanguageFormattings;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceItemRepositoryInterface;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
@@ -93,11 +97,8 @@ class ServiceInvoiceTest extends TestCase
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator($this->getNumberGeneratorSut());
$sut->addRenderer(
new TwigRenderer(
$this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock()
)
);
$twig = $this->getMockBuilder(Environment::class)->disableOriginalConstructor()->getMock();
$sut->addRenderer(new TwigRenderer($twig));
$this->assertEquals(1, \count($sut->getCalculator()));
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
@@ -113,8 +114,11 @@ class ServiceInvoiceTest extends TestCase
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Cannot create invoice model without template');
$query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$sut = $this->getSut([]);
$sut->createModel(new InvoiceQuery());
$sut->createModel($query);
}
/**
@@ -124,10 +128,10 @@ class ServiceInvoiceTest extends TestCase
{
$template = new InvoiceTemplate();
$template->setNumberGenerator('date');
self::assertNull($template->getLanguage());
$query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$query->setTemplate($template);
$sut = $this->getSut([]);
@@ -139,6 +143,30 @@ class ServiceInvoiceTest extends TestCase
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()
{
$template = new InvoiceTemplate();
@@ -148,6 +176,7 @@ class ServiceInvoiceTest extends TestCase
self::assertEquals('de', $template->getLanguage());
$query = new InvoiceQuery();
$query->setCustomers([new Customer()]);
$query->setTemplate($template);
$sut = $this->getSut([]);
@@ -159,6 +188,74 @@ class ServiceInvoiceTest extends TestCase
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()
{
$repository = $this->createMock(InvoiceRepository::class);

View File

@@ -733,10 +733,6 @@
<source>invoice.title</source>
<target>Faktura</target>
</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">
<source>button.print</source>
<target>Tisk</target>

View File

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

View File

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

View File

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