added pdf template and other invoice improvements (#1693)

This commit is contained in:
Kevin Papst
2020-05-10 16:19:59 +02:00
committed by GitHub
parent 4e1ffedc20
commit e3749c8e8f
45 changed files with 561 additions and 89 deletions

View File

@@ -19,10 +19,10 @@ jobs:
php-version: ${{ matrix.php }}
coverage: none
extensions: intl
tools: cs2pr:1.0.1
tools: cs2pr:1.1.0
- run: composer install --no-progress
- run: composer validate --no-check-all --strict
- run: vendor/bin/php-cs-fixer fix --dry-run --verbose --config=.php_cs.dist --using-cache=no --show-progress=none --format=checkstyle | cs2pr
- run: vendor/bin/phpstan analyse src -c phpstan.neon --level=4 --no-progress --error-format=checkstyle | cs2pr
- run: vendor/bin/phpstan analyse src -c phpstan.neon --level=5 --no-progress --error-format=checkstyle | cs2pr
- run: vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=4 --no-progress --error-format=checkstyle | cs2pr
- run: composer kimai:code-lint

View File

@@ -12,23 +12,29 @@ Perform EACH version specific task between your version and the new one, otherwi
- Invoice renderer `CSV` was removed
### Developer
- **BC break**: interface method signature `HtmlToPdfConverter::convertToPdf` changed
## [1.9](https://github.com/kevinpapst/kimai2/releases/tag/1.9)
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
- The directory `var/data/invoices/` will be used to store archived invoice files (check file permissions)
- The default invoice number format changed. If you want to use the old one: configure `{date}` as format - see [invoice documentation](https://www.kimai.org/documentation/invoices.html)
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
- Invoice templates that use the templates variables `${activity.X}` or `${project.X}` should be checked and possibly adapted, as multi-select is now possible for filtering
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs
- Invoice templates that use the templates variables `${activity.X}` or `${project.X}` should be checked and possibly adapted, as multi-select is now possible for filtering
- Invoice templates have access to all meta-fields as variables, not only the ones marked as visible
- Rates configuration/structure changed for customer, project and activity
- **Invoice templates**: rates variables were removed
Permission changes:
- `history_invoice` - NEW: grants all features for the new invoice archive (by default for all admins)
### Developer
- BC break: `InvoiceItemInterface` has new methods `getType()` and `getCategory()`
- **BC break**: `InvoiceItemInterface` has new methods `getType()` and `getCategory()`
- **BC break**: API fields changed - see new `/rates` endpoints
## [1.8](https://github.com/kevinpapst/kimai2/releases/tag/1.8)

2
assets/invoice-pdf.js Normal file
View File

@@ -0,0 +1,2 @@
require('./sass/invoice-pdf.scss');

View File

@@ -0,0 +1,104 @@
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
body {
font-family: sans-serif;
font-size: 10pt;
margin: 0;
padding: 0;
}
table, tr, td, p {
padding: 0;
margin: 0;
}
th {
border: none;
font-weight: bold;
text-align: left;
}
td {
vertical-align: top;
}
.text-right {
text-align: right;
}
.text-nowrap {
white-space: nowrap;
}
.date {
color: #666;
font-size: 80%;
text-align: right;
padding-top: 6px;
}
.wrapper {
margin: 4px;
}
table.addresses, p {
line-height: 14pt;
}
/* The address table */
table.addresses {
width: 100%;
margin-bottom: 30px;
}
/* The PDF header */
table.header {
border-spacing: 0;
padding-bottom: 4px;
border-bottom: 1px solid #ccc;
width: 100%;
}
table.header .title {
font-weight: normal;
font-size: 140%;
}
/* The PDF footer */
table.footer {
border-spacing: 0;
border-top: 1px solid #ccc;
font-size: 80%;
padding-top: 8px;
width: 100%;
line-height: 14px;
}
table.footer td {
vertical-align: bottom;
}
/* The invoice items list */
table.items {
width: 100%;
border-collapse: collapse;
margin-top: 50px;
margin-bottom: 50px;
}
table.items thead th,
table.items tbody td {
border-bottom: 1px solid #ccc;
}
table.items thead th.first,
table.items tbody td.first {
padding-left: 0;
}
table.items thead th.last,
table.items tbody td.last,
table.items tfoot td.last{
padding-right: 0;
}
table.items td,
table.items th {
padding: 8px 6px;
}
table.items tfoot td,
table.items tfoot th {
padding: 20px 0 0 0;
line-height: 1px;
}
table.items tr.odd {
background-color: #f5f5f5;
}

View File

@@ -38,7 +38,7 @@ body.invoice_print {
max-width: 210mm;
box-shadow: 0 0 20px rgba(80,80,80,0.7);
background: #fff;
border: 1px solid #f4f4f4;
border: none;
}
.page-header {

View File

@@ -17,7 +17,16 @@
"build/invoice.50473330.js"
],
"css": [
"build/invoice.3764169b.css"
"build/invoice.13d8ef4e.css"
]
},
"invoice-pdf": {
"js": [
"build/runtime.664a9501.js",
"build/invoice-pdf.1db2b744.js"
],
"css": [
"build/invoice-pdf.340721a4.css"
]
},
"chart": {
@@ -33,10 +42,10 @@
"build/runtime.664a9501.js",
"build/0.f7fae2b9.js",
"build/1.c24a9c6f.js",
"build/calendar.d2540934.js"
"build/calendar.33aa6a16.js"
],
"css": [
"build/calendar.8ede69b5.css"
"build/calendar.c8f3c1fd.css"
]
}
},
@@ -47,10 +56,12 @@
"build/app.d8c0b5e5.js": "sha384-VwB8dUrwHl8kZCpZjOlqXKfC39MtJNY/TSQj0oEwyFuqKGhrf0LS5++I06EOZo9b",
"build/app.a2628675.css": "sha384-0MsxtEEys+bTKR6Qi9LvMRg21TPh2x7JxTdVFTmySyjct4BpB9xuffwyk1BDsZDe",
"build/invoice.50473330.js": "sha384-2BXic5Sgorf2tXai6zSAN4wLY2dbg06L03/xMKW6itMcszvtnRArKzfBh6DNcF3f",
"build/invoice.3764169b.css": "sha384-oILxBeIu3sNXxVnxhHmyHxqvLY2Rm5P2lzuJVtbxBIcnSdLS31SUSW6nVWNej1eg",
"build/invoice.13d8ef4e.css": "sha384-B6RN/wZJToSBCZk2JeLokIqWEhbh+Eb9arYbt9dM+YoC2Z6PnCeTwTqSGyexWWJh",
"build/invoice-pdf.1db2b744.js": "sha384-bSdIeRCtEJiYYuc2reb0e5CpJ1Kbd1lQNEkElMTiq1SX0IINzdwJJYf6WnCcHrNC",
"build/invoice-pdf.340721a4.css": "sha384-+dk8iSafBIWj8bKpeEAkhkOR6G6o2OyL1ZeGuaIHuECtidl5cqoGbLSw+yKfSc0e",
"build/2.a47ad919.js": "sha384-1h2P/tsl+bh8qgJd7S9irQHKuns7UATVxeFGLOCH85GPFXfAdRzgzx3nk5MrxzrV",
"build/chart.c9943f57.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.d2540934.js": "sha384-+lalaW5vVwveu7esC/1Ys27a50kO9Nd5MiWGi0yih3GNNliOKE+hY9X0Eq47RflV",
"build/calendar.8ede69b5.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
"build/calendar.33aa6a16.js": "sha384-+lalaW5vVwveu7esC/1Ys27a50kO9Nd5MiWGi0yih3GNNliOKE+hY9X0Eq47RflV",
"build/calendar.c8f3c1fd.css": "sha384-ta519W5+NY1a8FBh7rD4zORYTfUXCYTLci0Z25nf4BfodmuXqyPVNEe7dSe2/fRd"
}
}

View File

@@ -0,0 +1 @@
(window.webpackJsonp=window.webpackJsonp||[]).push([["invoice-pdf"],{G2iK:function(n,i,o){},"ls+r":function(n,i,o){o("G2iK")}},[["ls+r","runtime"]]]);

View File

@@ -0,0 +1 @@
body{font-family:sans-serif;font-size:10pt}body,p,table,td,tr{margin:0;padding:0}th{border:none;font-weight:700;text-align:left}td{vertical-align:top}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{color:#666;font-size:80%;text-align:right;padding-top:6px}.wrapper{margin:4px}p,table.addresses{line-height:14pt}table.addresses{width:100%;margin-bottom:30px}table.header{border-spacing:0;padding-bottom:4px;border-bottom:1px solid #ccc;width:100%}table.header .title{font-weight:400;font-size:140%}table.footer{border-spacing:0;border-top:1px solid #ccc;font-size:80%;padding-top:8px;width:100%;line-height:14px}table.footer td{vertical-align:bottom}table.items{width:100%;border-collapse:collapse;margin-top:50px;margin-bottom:50px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{padding:20px 0 0;line-height:1px}table.items tr.odd{background-color:#f5f5f5}

File diff suppressed because one or more lines are too long

View File

@@ -4,11 +4,13 @@
"build/2.a47ad919.js": "build/2.a47ad919.js",
"build/app.css": "build/app.a2628675.css",
"build/app.js": "build/app.d8c0b5e5.js",
"build/calendar.css": "build/calendar.8ede69b5.css",
"build/calendar.js": "build/calendar.d2540934.js",
"build/calendar.css": "build/calendar.c8f3c1fd.css",
"build/calendar.js": "build/calendar.33aa6a16.js",
"build/chart.js": "build/chart.c9943f57.js",
"build/invoice.css": "build/invoice.3764169b.css",
"build/invoice.css": "build/invoice.13d8ef4e.css",
"build/invoice.js": "build/invoice.50473330.js",
"build/invoice-pdf.css": "build/invoice-pdf.340721a4.css",
"build/invoice-pdf.js": "build/invoice-pdf.1db2b744.js",
"build/runtime.js": "build/runtime.664a9501.js",
"build/fonts/fa-brands-400.eot": "build/fonts/fa-brands-400.088a34f7.eot",
"build/fonts/fa-brands-400.ttf": "build/fonts/fa-brands-400.273dc9bf.ttf",

View File

@@ -16,6 +16,7 @@ use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
@@ -46,6 +47,10 @@ class InvoiceCreateCommand extends Command
* @var CustomerRepository
*/
private $customerRepository;
/**
* @var ProjectRepository
*/
private $projectRepository;
/**
* @var InvoiceTemplateRepository
*/
@@ -67,6 +72,7 @@ class InvoiceCreateCommand extends Command
ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository,
ProjectRepository $projectRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
UserRepository $userRepository,
EventDispatcherInterface $eventDispatcher
@@ -74,6 +80,7 @@ class InvoiceCreateCommand extends Command
$this->serviceInvoice = $serviceInvoice;
$this->timesheetRepository = $timesheetRepository;
$this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository;
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
$this->userRepository = $userRepository;
$this->eventDispatcher = $eventDispatcher;
@@ -94,6 +101,7 @@ class InvoiceCreateCommand extends Command
->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('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')
->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created')
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported')
@@ -168,8 +176,9 @@ class InvoiceCreateCommand extends Command
}
$customersIDs = $input->getOption('customer');
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs)) {
$io->error('Could not determine generation mode, you need to set one of: customer, by-customer, by-project');
$projectIDs = $input->getOption('project');
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs) && empty($projectIDs)) {
$io->error('Could not determine generation mode, you need to set one of: customer, project, by-customer, by-project');
return 1;
}
@@ -235,15 +244,15 @@ class InvoiceCreateCommand extends Command
$defaultQuery->setCurrentUser($user);
$defaultQuery->setSearchTerm($searchTerm);
$defaultQuery->setMarkAsExported($markAsExported);
$defaultQuery->setState($exportedFilter);
$defaultQuery->setExported($exportedFilter);
/** @var Invoice[] $invoices */
$invoices = [];
/** @var Customer[] $customers */
$customers = [];
if (!empty($customersIDs)) {
/** @var Customer[] $customers */
$customers = [];
$customersIDs = explode(',', $customersIDs);
foreach ($customersIDs as $id) {
$tmp = $this->customerRepository->find($id);
@@ -255,6 +264,21 @@ class InvoiceCreateCommand extends Command
$customers[] = $tmp;
}
$invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output);
} elseif (!empty($projectIDs)) {
/** @var Project[] $projects */
$projects = [];
$projectIDs = explode(',', $projectIDs);
foreach ($projectIDs as $id) {
$tmp = $this->projectRepository->find($id);
if (null === $tmp) {
$io->error('Unknown project ID: ' . $id);
return 1;
}
$projects[] = $tmp;
}
$invoices = $this->createInvoicesForProjects($projects, $defaultQuery, $input, $output);
} elseif ($byActiveCustomer) {
$customers = $this->getActiveCustomers($start, $end);
$invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output);

View File

@@ -10,6 +10,7 @@
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -19,6 +20,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @ORM\UniqueConstraint(columns={"name"})
* }
* )
* @UniqueEntity("name")
*/
class InvoiceTemplate
{

View File

@@ -77,7 +77,13 @@ class InvoiceRendererType extends AbstractType
array_pop($parts);
}
return ucfirst(array_pop($parts));
$type = array_pop($parts);
if (\in_array(strtolower($type), ['json', 'txt', 'xml'])) {
return 'programmatic';
}
return ucfirst($type);
}
/**

View File

@@ -75,7 +75,7 @@ class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.currency' => $currency,
'entry.duration' => $item->getDuration(),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => number_format($item->getDuration() / 60),
'entry.duration_minutes' => (int) ($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),
'entry.begin_time' => $formatter->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),

View File

@@ -57,7 +57,7 @@ class InvoiceModelProjectHydrator implements InvoiceModelHydrator
$prefix . 'budget_money_plain' => $project->getBudget(),
$prefix . 'budget_time' => $project->getTimeBudget(),
$prefix . 'budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
$prefix . 'budget_time_minutes' => number_format($project->getTimeBudget() / 60),
$prefix . 'budget_time_minutes' => (int) ($project->getTimeBudget() / 60),
];
foreach ($project->getVisibleMetaFields() as $metaField) {

View File

@@ -45,7 +45,14 @@ final class PdfRenderer implements RendererInterface
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
'model' => $model
]);
$content = $this->converter->convertToPdf($content);
$content = $this->converter->convertToPdf($content, [
'setAutoTopMargin' => 'pad',
'setAutoBottomMargin' => 'pad',
'margin_top' => 12,
'margin_bottom' => 8,
]);
$filename = (string) new InvoiceFilename($model);
$response = new Response($content);

View File

@@ -91,7 +91,11 @@ class DateExtensions extends AbstractExtension
}
if (!$date instanceof DateTime) {
$date = new DateTime($date);
try {
$date = new DateTime($date);
} catch (\Exception $ex) {
return $date;
}
}
return date_format($date, $this->dateFormat);
@@ -109,7 +113,11 @@ class DateExtensions extends AbstractExtension
}
if (!$date instanceof DateTime) {
$date = new DateTime($date);
try {
$date = new DateTime($date);
} catch (\Exception $ex) {
return $date;
}
}
return $date->format($this->dateTimeFormat);
@@ -128,7 +136,11 @@ class DateExtensions extends AbstractExtension
}
if (!$date instanceof DateTime) {
$date = new DateTime($date);
try {
$date = new DateTime($date);
} catch (\Exception $ex) {
return $date;
}
}
$timezone = date_default_timezone_get();
@@ -158,7 +170,11 @@ class DateExtensions extends AbstractExtension
public function dateFormat($date, string $format)
{
if (!$date instanceof DateTime) {
$date = new DateTime($date);
try {
$date = new DateTime($date);
} catch (\Exception $ex) {
return $date;
}
}
return date_format($date, $format);

View File

@@ -193,7 +193,12 @@ class Extensions extends AbstractExtension
*/
public function currency($currency)
{
return Currencies::getSymbol($currency);
try {
return Currencies::getSymbol(strtoupper($currency));
} catch (\Exception $ex) {
}
return $currency;
}
/**
@@ -202,7 +207,12 @@ class Extensions extends AbstractExtension
*/
public function language($language)
{
return Languages::getName($language, $this->locale);
try {
return Languages::getName(strtolower($language), $this->locale);
} catch (\Exception $ex) {
}
return $language;
}
/**
@@ -211,9 +221,9 @@ class Extensions extends AbstractExtension
*/
public function country($country)
{
$country = strtoupper($country);
if (Countries::exists($country)) {
return Countries::getName($country);
try {
return Countries::getName(strtoupper($country));
} catch (\Exception $ex) {
}
return $country;

View File

@@ -14,7 +14,8 @@ interface HtmlToPdfConverter
/**
* Returns the binary content of the PDF, which can be saved as file or send via Reponse.
* @param string $html
* @param array $options
* @return mixed
*/
public function convertToPdf(string $html);
public function convertToPdf(string $html, array $options = []);
}

View File

@@ -27,12 +27,14 @@ class MPdfConverter implements HtmlToPdfConverter
/**
* @param string $html
* @param array $options
* @return mixed|string
* @throws \Mpdf\MpdfException
*/
public function convertToPdf(string $html)
public function convertToPdf(string $html, array $options = [])
{
$mpdf = new Mpdf(['tempDir' => $this->cacheDirectory]);
$options = array_merge($options, ['tempDir' => $this->cacheDirectory]);
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
// some OS do not follow the PHP default settings

View File

@@ -32,7 +32,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.activityUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.activityUpdate'}) }}

View File

@@ -44,7 +44,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.customerUpdate kimai.customerTeamUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.customerUpdate kimai.customerTeamUpdate'}) }}

View File

@@ -19,7 +19,7 @@
{% block main %}
{% if widgets is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found() }}
{% else %}
{% for row in widgets %}
{{ render_widget(row) }}

View File

@@ -85,7 +85,7 @@
{% if preview_show %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found() }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {}) }}
{% set totalAmount = {} %}

View File

@@ -33,7 +33,7 @@
{% set actions = actions|merge({'visibility': '#modal_invoice_template'}) %}
{% if is_granted('manage_invoice_template') %}
{% set actions = actions|merge({'create': path('admin_invoice_template_create')}) %}
{% set actions = actions|merge({'create': {'url': path('admin_invoice_template_create'), 'class': 'modal-ajax-form'}}) %}
{% endif %}
{% if is_granted('upload_invoice_template') %}

View File

@@ -71,7 +71,7 @@
{% if preview %}
{% if model.calculator is empty or model.calculator.entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found() }}
{% else %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set entries = model.calculator.entries %}

View File

@@ -29,11 +29,11 @@
{% block main %}
{% if entries is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found() }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {}) }}
{% for entry in entries %}
<tr>
<tr class="alternative-link open-edit" data-href="{{ path('admin_invoice_download', {'id': entry.id}) }}">
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.createdAt|date_short }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>

View File

@@ -0,0 +1,160 @@
<!DOCTYPE html>
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
{% set language = model.template.language|default(fallback) %}
{% set isDecimal = model.template.decimalDuration|default(false) %}
{% set currency = model.currency %}
<html lang="{{ language }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<style type="text/css">
{{ encore_entry_css_source('invoice-pdf')|raw }}
</style>
</head>
<body>
<!--mpdf
<htmlpageheader name="header">
<table class="header">
<tr>
<td class="title">
{{ model.template.title }}
</td>
<td class="date">
{{ 'label.date'|trans({}, 'messages', language) }}: {{ model.invoiceDate|date_short }}
</td>
</tr>
</table>
</htmlpageheader>
<sethtmlpageheader name="header" page="ALL" value="on" show-this-page="1" />
<htmlpagefooter name="footer">
<table class="footer">
<tr>
<td>
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>:
{{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>:
{{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</td>
<td align="right">
{{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }}
</td>
</tr>
</table>
</htmlpagefooter>
<sethtmlpagefooter page="ALL" value="on" name="footer" show-this-page="1" />
mpdf-->
<div class="wrapper">
<table class="addresses">
<tr>
<td width="60%">
{{ 'invoice.to'|trans({}, 'messages', language) }}
<br>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong>
<br>
{{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }}
{% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br>
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
{% endif %}
</td>
<td>
{{ 'invoice.from'|trans({}, 'messages', language) }}
<br>
<strong>{{ model.template.company }}</strong>
<br>
{{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
{{ model.template.vatId }}
{% endif %}
</td>
</tr>
</table>
<p>
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
{{ model.dueDate|date_short }}
</p>
<table class="items">
<thead>
<tr>
<th class="first">{{ 'label.date'|trans({}, 'messages', language) }}</th>
<th>{{ 'label.description'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.unit_price'|trans({}, 'messages', language) }}</th>
<th class="text-right">{{ 'label.amount'|trans({}, 'messages', language) }}</th>
<th class="last text-right">{{ 'label.total_rate'|trans({}, 'messages', language) }}</th>
</tr>
</thead>
<tbody>
{% for id, entry in model.calculator.entries %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr{# class="{{ cycle(['odd', 'even'], id) }}"#}>
<td nowrap class="first text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true">
{% if entry.description is not empty %}
{{ entry.description|nl2br }}
{% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td nowrap class="text-nowrap text-right">{{ rate|money(currency) }}</td>
<td nowrap class="text-nowrap text-right">{{ duration }}</td>
<td nowrap class="last text-nowrap text-right">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans({}, 'messages', language) }}
</td>
<td class="last text-right">{{ model.calculator.subtotal|money(currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)
</td>
<td class="last text-right">{{ model.calculator.tax|money(currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
</td>
<td class="last text-right">
<strong>{{ model.calculator.total|money(currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
{% if model.template.paymentTerms is not empty %}
<p>
{{ model.template.paymentTerms|md2html }}
</p>
{% endif %}
</div>
</body>
</html>

View File

@@ -29,7 +29,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.invoiceTemplateUpdate') }}
{% endif %}
{{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.invoiceTemplateUpdate'}) }}

View File

@@ -458,3 +458,14 @@
</tbody>
</table>
{% endmacro %}
{% macro nothing_found(events) %}
{{ _self.callout('warning', 'error.no_entries_found') }}
{% if events is not empty %}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('{{ events }}');
});
</script>
{% endif %}
{% endmacro %}

View File

@@ -37,7 +37,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.projectUpdate kimai.projectTeamUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.projectUpdate kimai.projectTeamUpdate'}) }}

View File

@@ -11,12 +11,7 @@
{% block main %}
{% if tags|length == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.tagUpdate');
});
</script>
{{ widgets.nothing_found('kimai.tagUpdate') }}
{% else %}
{% set columns = {

View File

@@ -11,7 +11,7 @@
{% block main %}
{% if teams|length == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.teamUpdate') }}
{% else %}
{% set columns = {

View File

@@ -57,12 +57,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
<script type="text/javascript">
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.timesheetUpdate');
});
</script>
{{ widgets.nothing_found('kimai.timesheetUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'striped': not showSummary, 'reload': 'kimai.timesheetUpdate'}) }}

View File

@@ -35,7 +35,7 @@
{% block main %}
{% if entries.count == 0 %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found('kimai.userUpdate') }}
{% else %}
{{ tables.datatable_header(tableName, columns, query, {'reload': 'kimai.userUpdate'}) }}

View File

@@ -14,7 +14,7 @@
{% block profile_content %}
{% if years is empty %}
{{ widgets.callout('warning', 'error.no_entries_found') }}
{{ widgets.nothing_found() }}
{% endif %}
<script type="text/javascript">

View File

@@ -17,6 +17,7 @@ use App\Entity\Project;
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;
@@ -70,6 +71,7 @@ class InvoiceCreateCommandTest extends KernelTestCase
$container->get(ServiceInvoice::class),
$container->get(TimesheetRepository::class),
$container->get(CustomerRepository::class),
$container->get(ProjectRepository::class),
$container->get(InvoiceTemplateRepository::class),
$container->get(UserRepository::class),
$container->get('event_dispatcher')
@@ -167,6 +169,11 @@ class InvoiceCreateCommandTest extends KernelTestCase
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 3, '--template' => 'x'], 'Unknown customer ID: 3');
}
public function testCreateWithInvalidProject()
{
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--project' => 3, '--template' => 'x'], 'Unknown project ID: 3');
}
public function testCreateInvoice()
{
$fixture = new InvoiceFixtures();
@@ -251,6 +258,19 @@ class InvoiceCreateCommandTest extends KernelTestCase
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
}
public function testCreateInvoiceByProjectId()
{
$start = new \DateTime('-2 months');
$end = new \DateTime();
$this->prepareFixtures($start);
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'all', '--project' => '1', '--template' => 'Invoice', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]);
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
}
public function testCreateInvoiceByProjectWithPreview()
{
$start = new \DateTime('-2 months');

View File

@@ -25,6 +25,7 @@ class InvoiceDocumentRepositoryTest extends TestCase
protected static $defaultDocuments = [
'company.docx',
'default.html.twig',
'default-pdf.pdf.twig',
'freelancer.html.twig',
'timesheet.html.twig',
'text.txt.twig',

View File

@@ -91,6 +91,7 @@ class DateExtensionsTest extends TestCase
['de', new \DateTime('1980-12-14'), '14.12.1980'],
['ru', new \DateTime('1980-12-14'), '14.12.1980'],
['ru', '1980-12-14', '14.12.1980'],
['ru', 1.2345, 1.2345],
];
}
@@ -115,6 +116,7 @@ class DateExtensionsTest extends TestCase
['en', new \DateTime('7 January 2010'), '2010-01-07 12:01 AM'],
['de', (new \DateTime('1980-12-14'))->setTime(13, 27, 55), '14.12.1980 13:27:55'],
['de', '1980-12-14 13:27:55', '14.12.1980 13:27:55'],
['de', 1.2345, 1.2345],
];
}
@@ -144,6 +146,7 @@ class DateExtensionsTest extends TestCase
$sut = $this->getSut('en', []);
$this->assertEquals('2010-01-07T17:43:21+01:00', $sut->dateFormat($date, 'c'));
$this->assertStringStartsWith('2010-01-07T17:43:21', $sut->dateFormat('7 January 2010 17:43:21', 'c'));
$this->assertEquals(2010.0107, $sut->dateFormat(2010.0107, 'c'));
}
public function testTime()
@@ -181,5 +184,6 @@ class DateExtensionsTest extends TestCase
$this->assertEquals('2019-08-17 12:29:47', $sut->dateTimeFull($dateTime));
$this->assertEquals('2019-08-17 12:29:47', $sut->dateTimeFull('2019-08-17 12:29:47'));
$this->assertEquals(189.45, $sut->dateTimeFull(189.45));
}
}

View File

@@ -95,6 +95,8 @@ class ExtensionsTest extends TestCase
'EUR' => '€',
'USD' => '$',
'RUB' => 'RUB',
'rub' => 'RUB',
123 => 123,
];
$sut = $this->getSut($this->localeEn);
@@ -109,6 +111,8 @@ class ExtensionsTest extends TestCase
'DE' => 'Germany',
'RU' => 'Russia',
'ES' => 'Spain',
'es' => 'Spain',
'12' => '12',
];
$sut = $this->getSut($this->localeEn);
@@ -123,6 +127,8 @@ class ExtensionsTest extends TestCase
'de' => 'German',
'ru' => 'Russian',
'es' => 'Spanish',
'ES' => 'Spanish',
'12' => '12',
];
$sut = $this->getSut($this->localeEn);

View File

@@ -6,9 +6,59 @@
<source>label.invoice_renderer</source>
<target>Rechnungsdokument</target>
</trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Achtung: existierende Dateien werden übschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="programmatic">
<source>programmatic</source>
<target>Zur Weiterverarbeitung</target>
</trans-unit>
<trans-unit id="Html">
<source>Html</source>
<target>HTML (zum Drucken via Browser)</target>
</trans-unit>
<trans-unit id="Docx">
<source>Docx</source>
<target>Microsoft Word (Docx)</target>
</trans-unit>
<trans-unit id="Xls">
<source>Xls</source>
<target>Microsoft Excel (Xls)</target>
</trans-unit>
<trans-unit id="Ods">
<source>Ods</source>
<target>Open Office (Tabelle)</target>
</trans-unit>
<trans-unit id="Xlsx">
<source>Xlsx</source>
<target>Microsoft Excel (Xlsx)</target>
</trans-unit>
<trans-unit id="Pdf">
<source>Pdf</source>
<target>PDF</target>
</trans-unit>
<!-- Renderer -->
<trans-unit id="default">
<source>default</source>
<target>Rechnung (Standard)</target>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="default-pdf">
<source>default-pdf</source>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="xml">
<source>xml</source>
<target>XML</target>
</trans-unit>
<trans-unit id="javascript">
<source>javascript</source>
<target>JSON</target>
</trans-unit>
<trans-unit id="text">
<source>text</source>
<target>Text</target>
</trans-unit>
<trans-unit id="timesheet">
<source>timesheet</source>
@@ -20,23 +70,15 @@
</trans-unit>
<trans-unit id="open-spreadsheet">
<source>open-spreadsheet</source>
<target>Calc Tabellendokument</target>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="spreadsheet">
<source>spreadsheet</source>
<target>Excel Tabelle</target>
</trans-unit>
<trans-unit id="export">
<source>export</source>
<target>Datenexport</target>
<target>Rechnung</target>
</trans-unit>
<trans-unit id="company">
<source>company</source>
<target>Firmen-Rechnung</target>
</trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Achtung: existierende Dateien werden übschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX</target>
<target>Rechnung</target>
</trans-unit>
</body>
</file>

View File

@@ -6,9 +6,59 @@
<source>label.invoice_renderer</source>
<target>Invoice document</target>
</trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="programmatic">
<source>programmatic</source>
<target>For further processing</target>
</trans-unit>
<trans-unit id="Html">
<source>Html</source>
<target>HTML (for printing via Browser)</target>
</trans-unit>
<trans-unit id="Docx">
<source>Docx</source>
<target>Microsoft Word (Docx)</target>
</trans-unit>
<trans-unit id="Xls">
<source>Xls</source>
<target>Microsoft Excel (Xls)</target>
</trans-unit>
<trans-unit id="Ods">
<source>Ods</source>
<target>Open Office (Spreadsheet)</target>
</trans-unit>
<trans-unit id="Xlsx">
<source>Xlsx</source>
<target>Microsoft Excel (Xlsx)</target>
</trans-unit>
<trans-unit id="Pdf">
<source>Pdf</source>
<target>PDF</target>
</trans-unit>
<!-- Renderer -->
<trans-unit id="default">
<source>default</source>
<target>Invoice (default)</target>
<target>Invoice</target>
</trans-unit>
<trans-unit id="default-pdf">
<source>default-pdf</source>
<target>Invoice</target>
</trans-unit>
<trans-unit id="xml">
<source>xml</source>
<target>XML</target>
</trans-unit>
<trans-unit id="javascript">
<source>javascript</source>
<target>JSON</target>
</trans-unit>
<trans-unit id="text">
<source>text</source>
<target>Text</target>
</trans-unit>
<trans-unit id="timesheet">
<source>timesheet</source>
@@ -20,23 +70,15 @@
</trans-unit>
<trans-unit id="open-spreadsheet">
<source>open-spreadsheet</source>
<target>Calc Spreadsheet</target>
<target>Invoice</target>
</trans-unit>
<trans-unit id="spreadsheet">
<source>spreadsheet</source>
<target>Excel</target>
</trans-unit>
<trans-unit id="export">
<source>export</source>
<target>Data export</target>
<target>Invoice</target>
</trans-unit>
<trans-unit id="company">
<source>company</source>
<target>Company invoice</target>
</trans-unit>
<trans-unit id="help.upload">
<source>help.upload</source>
<target>Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
<target>Invoice</target>
</trans-unit>
</body>
</file>

View File

@@ -11,6 +11,7 @@ Encore
.addEntry('app', './assets/app.js')
.addEntry('invoice', './assets/invoice.js')
.addEntry('invoice-pdf', './assets/invoice-pdf.js')
.addEntry('chart', './assets/chart.js')
.addEntry('calendar', './assets/calendar.js')
.copyFiles({ from: './assets/images', to: 'images/[path][name].[ext]' })
@@ -26,7 +27,7 @@ Encore
.enableSassLoader()
.autoProvidejQuery()
// prevent that moment locales will be included which we don't need
// prevent that unused moment locales will be included
.addPlugin(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/))
.configureBabel(null, {