added new invoice fields, improved invoice templates (#1258)

This commit is contained in:
Kevin Papst
2019-11-18 12:01:26 +01:00
committed by GitHub
parent 2287c9951c
commit 47bd8b0ce7
50 changed files with 1530 additions and 1222 deletions

2
.gitignore vendored
View File

@@ -22,6 +22,8 @@ templates/invoice/renderer/.~lock*
/var/invoices/* /var/invoices/*
/var/packages/*
!var/packages/.gitkeep
/var/plugins/* /var/plugins/*
!var/plugins/.gitkeep !var/plugins/.gitkeep

View File

@@ -10,6 +10,8 @@ Perform EACH version specific task between your version and the new one, otherwi
## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6) ## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6)
- Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates) - if you use them, add them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html))
- Added new invoice fields (VAT, bank account) and customer fields (VAT), check if you want to update your settings
- New functionality to manage permissions via Admin UI were added. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html) - New functionality to manage permissions via Admin UI were added. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html)
- New database tables were created, don't forget to [run the migrations](https://www.kimai.org/documentation/updates.html) - New database tables were created, don't forget to [run the migrations](https://www.kimai.org/documentation/updates.html)

View File

@@ -197,6 +197,10 @@ export default class KimaiAjaxModalForm extends KimaiClickHandlerReducedInTableR
err = '[' + xhr.status +'] ' + xhr.statusText; err = '[' + xhr.status +'] ' + xhr.statusText;
} }
alert.error(message, err); alert.error(message, err);
// this is useful for changing form fields and retrying to save (and in development to test form changes)
setTimeout(function() {
btn.button('reset');
}, 1500);
} }
}); });
}); });

View File

@@ -5,301 +5,284 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
.invoice { body.invoice_print {
margin: 0; background-color: #eee;
border: none;
}
@media (min-width: $screen-sm-min) {
.invoice { .invoice {
margin: 25px; margin: 105px auto 30px auto;
padding: 50px 65px;
min-height: 297mm;
position: relative;
max-width: 210mm;
box-shadow: 0 0 20px rgba(80,80,80,0.7);
} }
}
.page-header h3 {
.page-header-small{ border-color: #ddd;
font-size: 14px;
}
div.invoice-address {
margin-top: 30px;
margin-bottom: 30px;
}
table.invoice-records {}
table.invoice-meta th {
padding-right: 40px;
}
table.invoice-sum th {
width: 70%;
}
table.invoice-sum th, table.invoice-sum td {
text-align: right;
}
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice {
font: 15px/1 $font-family-sans-serif;
box-sizing: border-box;
h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
} }
h2 { div.invoice-address {
font: bold 1.5em sans-serif; margin-top: 30px;
margin-bottom: 1em; margin-bottom: 30px;
} }
/* table */ table.invoice-meta th {
padding-right: 40px;
table {
table-layout: fixed;
width: 100%;
} }
table.invoice-sum th {
table { width: 70%;
border-collapse: separate;
border-spacing: 0;
} }
table.invoice-sum th, table.invoice-sum td {
/* header */
header {
}
header:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
header address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-weight: bold;
font-size: 0.7em;
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
article.footer {
margin-top: 60px;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
text-align: right; text-align: right;
width: 20%; }
.invoice-items {
margin-top: 2em;
margin-bottom: 3em;
.table {
thead th {
font-weight: bold;
border-bottom: 1px solid #ddd;
padding-bottom: 15px
}
tfoot {
border-top: 1px solid #ddd;
tr:first-child th,
tr:first-child td {
padding-top: 15px
}
tr {
th,td {
border:none;
padding: 4px 8px;
}
}
}
}
} }
/* footer */ .footer {
border-top: 1px solid #ccc;
footer {
margin-top: 50px; margin-top: 50px;
}
footer table {
border-top: 1px solid #000;
font-size: 0.8em; font-size: 0.8em;
padding-top: 5px; padding-top: 10px;
} width: 100%;
footer th {
text-align: left;
font-weight: bold;
margin-bottom: 10px;
}
footer table th:nth-child(1) {
width: 32%;
}
footer table th:nth-child(3) {
width: 28%;
}
footer td {
text-align: left;
vertical-align: bottom;
}
/* aside */
aside {
position: absolute;
top: 10in;
width: 7.5in;
}
aside h1 {
border: none;
border-width: 0 0 1px;
margin: 0 0 1em;
}
aside h1 {
border-color: #999;
border-bottom-style: solid;
}
aside p {
font-size: 85%;
}
aside div {
margin-top: 15px;
}
aside div p {
line-height: 1.4;
}
aside p span {
width: 130px;
font-weight: bold;
display: inline-block;
}
}
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
} }
/* ================================ FREELANCER INVOICE ================================ */
#freelancer-invoice { #freelancer-invoice {
margin: 1em 2em;
box-sizing: border-box;
span:empty {
display: none; h1 {
font: bold 100% sans-serif;
letter-spacing: 0.5em;
text-align: center;
text-transform: uppercase;
}
h2 {
font: bold 1.5em sans-serif;
margin-bottom: 1em;
}
/* table */
table {
table-layout: fixed;
width: 100%;
}
table {
border-collapse: separate;
border-spacing: 0;
}
/* header */
header {
&:after {
clear: both;
content: "";
display: table;
font-size: 1em;
}
address {
float: left;
font-style: normal;
line-height: 1.25;
margin: 0 1em 1em 0;
font-size: 0.7em;
}
}
article address {
font-size: 1em;
float: left;
margin: 1em 0;
}
article.address {
margin-bottom: 1.5em;
}
/* article */
article, table.meta, table.inventory {
margin: 1em 0;
}
article:after {
clear: both;
content: "";
display: table;
}
article h1 {
clip: rect(0 0 0 0);
position: absolute;
}
article p {
margin-bottom: 20px;
}
/* table meta & balance */
table.meta:after, table.balance:after {
clear: both;
content: "";
display: table;
}
/* table meta */
table.meta {
float: right;
width: 50%;
font-size: 85%;
th {
text-align: right;
font-weight: normal;
}
td {
width: 120px;
text-align: right;
}
}
/* table balance */
table.balance {
float: right;
th {
text-align: right;
font-weight: normal;
}
td {
width: 140px;
text-align: right;
line-height: 22px;
}
.total {
font-weight: bold;
}
}
/* table items */
table.inventory {
clear: both;
width: 100%;
margin-top: 2em;
border-bottom: 1px solid #000;
}
table.inventory th,
table.inventory td {
padding: 5px;
}
table.inventory thead th {
font-weight: bold;
border-width: 0 0 1px 0;
border-bottom: 1px solid #000;
padding-bottom: 15px
}
table.inventory tbody tr:first-child td {
padding-top: 15px
}
table.inventory tbody tr:last-child td {
padding-bottom: 15px
}
table.inventory td:nth-child(1),
table.inventory th:nth-child(1) {
text-align: left;
}
table.inventory td:nth-child(2),
table.inventory th:nth-child(2) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(3),
table.inventory th:nth-child(3) {
text-align: center;
width: 20%;
}
table.inventory td:nth-child(4),
table.inventory th:nth-child(4) {
text-align: right;
width: 20%;
} }
.add, .cut { .footer {
display: none; border-color: #000;
} }
} }
.invoice { /* ================================ PRINTING RULES ================================ */
width: unset;
@media print {
* {
-webkit-print-color-adjust: exact;
}
body, .wrapper {
margin: 0;
padding: 0;
background-color: white;
}
#freelancer-invoice {
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
.invoice {
margin: 1em 2em;
font-size: 90%;
width: unset;
padding: 0;
min-height: unset;
position: unset;
max-width: unset;
box-shadow: unset;
}
} }
} }

View File

@@ -76,6 +76,12 @@
"symfony/thanks": "^1.0", "symfony/thanks": "^1.0",
"symfony/web-server-bundle": "^4.0" "symfony/web-server-bundle": "^4.0"
}, },
"repositories": [
{
"type": "artifact",
"url": "var/packages/"
}
],
"config": { "config": {
"platform": { "platform": {
"php": "7.2" "php": "7.2"

1393
composer.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,10 +5,10 @@
"build/runtime.4ee6be68.js", "build/runtime.4ee6be68.js",
"build/0.a87622f3.js", "build/0.a87622f3.js",
"build/1.c1bee41f.js", "build/1.c1bee41f.js",
"build/app.120bf1d1.js" "build/app.c36bdaf3.js"
], ],
"css": [ "css": [
"build/app.ca6a8b7f.css" "build/app.e5eb271e.css"
] ]
}, },
"chart": { "chart": {
@@ -35,8 +35,8 @@
"build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd", "build/runtime.4ee6be68.js": "sha384-xNNrNinl64G3nCUrIskgSjU0mUXXCB9lj6XCSInBTwxSKXk8uTMafnLHtdWdIGtd",
"build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok", "build/0.a87622f3.js": "sha384-ncT/BKhCsqH6jhxwdsSG95m1ei7ZZjeZtzH1262h+OPUU80TSFFE3dt+abcHHMok",
"build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI", "build/1.c1bee41f.js": "sha384-7UVWcP6Hefp2k/CrtGSITKXx4dSZqtvpAiU8WX7dClETkzMewrUjoCRtVXQ5j3KI",
"build/app.120bf1d1.js": "sha384-2Eohdb33hChqGmyJIUGgZFiqzPfUDIv74IoMAX+09VozhJDD/GtPIg5+N2CCOACl", "build/app.c36bdaf3.js": "sha384-lU6L1apGsiE2oAJfhqW7YaQo+J1eA5ysuOCfERdfGp63Z9ouvhQ9Ov3DlN+6aYXK",
"build/app.ca6a8b7f.css": "sha384-NrITZnNaaIkzEjZkOjxciIbq6V6dKC9VlbJfxliLc2vi8n0113/yLfhbwi5rjzg2", "build/app.e5eb271e.css": "sha384-BNUNuu56X2x+Xmn1JrQe6d3mt6R+QZu7OgR/4OXkBDueSE6vu0UqmRaDPkyiRzMw",
"build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q", "build/2.7be60d8d.js": "sha384-txR0QG+838LKYtPQ99Gx4OU7WmgN9J3joZEyGwIskSz74EN1T4/IBVnmNaKiFN1q",
"build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y", "build/chart.0af3f813.js": "sha384-I57c9DtU3AOG2kzKqIZkIu0hi1aGYHRZ5QG4LKC9+9slzJnAMttPGXoL2cQG3m6y",
"build/calendar.6f94d071.js": "sha384-CnbCkgF1mbGOycK2A78CF1ZsQ4O07ol9U4gB0Tiec0wKu1mNw8lE22lvoRNzYs/8", "build/calendar.6f94d071.js": "sha384-CnbCkgF1mbGOycK2A78CF1ZsQ4O07ol9U4gB0Tiec0wKu1mNw8lE22lvoRNzYs/8",

View File

@@ -2,8 +2,8 @@
"build/0.a87622f3.js": "build/0.a87622f3.js", "build/0.a87622f3.js": "build/0.a87622f3.js",
"build/1.c1bee41f.js": "build/1.c1bee41f.js", "build/1.c1bee41f.js": "build/1.c1bee41f.js",
"build/2.7be60d8d.js": "build/2.7be60d8d.js", "build/2.7be60d8d.js": "build/2.7be60d8d.js",
"build/app.css": "build/app.ca6a8b7f.css", "build/app.css": "build/app.e5eb271e.css",
"build/app.js": "build/app.120bf1d1.js", "build/app.js": "build/app.c36bdaf3.js",
"build/calendar.css": "build/calendar.36ef2a02.css", "build/calendar.css": "build/calendar.36ef2a02.css",
"build/calendar.js": "build/calendar.6f94d071.js", "build/calendar.js": "build/calendar.6f94d071.js",
"build/chart.js": "build/chart.0af3f813.js", "build/chart.js": "build/chart.0af3f813.js",

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '1.5'; public const VERSION = '1.6';
/** /**
* The current release status, either "stable" or "dev" * The current release status, either "stable" or "dev"
*/ */
public const STATUS = 'stable'; public const STATUS = 'dev';
/** /**
* The software name * The software name
*/ */

View File

@@ -302,10 +302,6 @@ class ProfileController extends AbstractController
private function createPreferencesForm(User $user): FormInterface private function createPreferencesForm(User $user): FormInterface
{ {
// we need to prepare the user preferences, which is done via an EventSubscriber
$event = new PrepareUserEvent($user);
$this->dispatcher->dispatch($event);
return $this->createForm( return $this->createForm(
UserPreferencesForm::class, UserPreferencesForm::class,
$user, $user,

View File

@@ -46,7 +46,7 @@ class CustomerFixtures extends Fixture
*/ */
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
$faker = Factory::create(); $faker = Factory::create('at_AT');
$amountCustomers = rand(self::MIN_CUSTOMERS, self::MAX_CUSTOMERS); $amountCustomers = rand(self::MIN_CUSTOMERS, self::MAX_CUSTOMERS);
for ($c = 1; $c <= $amountCustomers; $c++) { for ($c = 1; $c <= $amountCustomers; $c++) {
@@ -100,6 +100,7 @@ class CustomerFixtures extends Fixture
->setCountry($faker->countryCode) ->setCountry($faker->countryCode)
->setTimezone($faker->timezone) ->setTimezone($faker->timezone)
->setVisible($visible) ->setVisible($visible)
->setVatId($faker->vat)
; ;
if (rand(0, 3) % 3) { if (rand(0, 3) % 3) {

View File

@@ -31,7 +31,9 @@ class InvoiceFixtures extends Fixture
*/ */
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
foreach ($this->getInvoiceConfigs() as $invoiceConfig) { $faker = Factory::create('at_AT');
foreach ($this->getInvoiceConfigs($faker) as $invoiceConfig) {
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms // name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
@@ -44,8 +46,11 @@ class InvoiceFixtures extends Fixture
->setCompany($invoiceConfig[5]) ->setCompany($invoiceConfig[5])
->setVat($invoiceConfig[6]) ->setVat($invoiceConfig[6])
->setDueDays($invoiceConfig[7]) ->setDueDays($invoiceConfig[7])
->setAddress($invoiceConfig[8]) ->setPaymentTerms($invoiceConfig[8])
->setPaymentTerms($invoiceConfig[9]) ->setVatId($faker->vat)
->setAddress($this->generateAddress($faker))
->setContact($this->generateContact($faker))
->setPaymentDetails($this->generatePaymentDetails($faker))
; ;
$manager->persist($template); $manager->persist($template);
@@ -53,10 +58,8 @@ class InvoiceFixtures extends Fixture
} }
} }
private function getInvoiceConfigs() private function getInvoiceConfigs(Generator $faker)
{ {
$faker = Factory::create();
$paymentTerms = $paymentTerms =
'I would like to thank you for your confidence and will gladly be there for you in the future.' . 'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL . PHP_EOL .
@@ -64,11 +67,9 @@ class InvoiceFixtures extends Fixture
'as reference.' 'as reference.'
; ;
$address = $paymentTerms_alt =
$faker->streetAddress . PHP_EOL . $faker->firstName . ', thank you very much. We really appreciate your business.' . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL . 'Please send payments before the due date.I would like to thank you for your confidence and will gladly be there for you in the future.'
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
; ;
$paymentTerms_de = $paymentTerms_de =
@@ -85,17 +86,14 @@ class InvoiceFixtures extends Fixture
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms // name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
return [ return [
['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $address, $paymentTerms], ['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $paymentTerms],
['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $this->generateAddress($faker), $paymentTerms_de], ['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $paymentTerms_de],
['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $this->generateAddress($faker), ''], ['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $paymentTerms_alt],
['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $this->generateAddress($faker, true), $this->generatePaymentTerms($faker)], ['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $paymentTerms_alt],
['Export (CSV, user-group)', 'User-grouped', 'export', 'user', 'default', $faker->company, 7, 28, '', ''],
['Export (ODS)', 'Spreadsheet', 'open-spreadsheet', 'default', 'default', $faker->company, 19, 14, '', ''],
['Export (XLSX, user-group)', 'Spreadsheet', 'spreadsheet', 'user', 'default', $faker->company, 13, 10, '', ''],
]; ];
} }
protected function generatePaymentTerms(Generator $faker) protected function generatePaymentDetails(Generator $faker)
{ {
return return
'Acme Bank' . PHP_EOL . 'Acme Bank' . PHP_EOL .
@@ -104,15 +102,17 @@ class InvoiceFixtures extends Fixture
; ;
} }
protected function generateAddress(Generator $faker, $lineBreaks = false) protected function generateContact(Generator $faker)
{ {
if (!$lineBreaks) { return
return 'Phone: ' . $faker->phoneNumber . PHP_EOL .
$faker->name . ' - ' . 'Email: ' . $faker->safeEmail . PHP_EOL .
$faker->streetAddress . '-' . 'Web: www.' . $faker->domainName
$faker->postcode . ' ' . $faker->city; ;
} }
protected function generateAddress(Generator $faker)
{
return return
'Kimai Inc.' . PHP_EOL . 'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL . $faker->streetAddress . PHP_EOL .

View File

@@ -55,7 +55,7 @@ abstract class AbstractMigration extends BaseAbstractMigration implements Contai
/** /**
* Whether we should deactivate foreign key support for SQLite. * Whether we should deactivate foreign key support for SQLite.
* This is required, if columns are changed. * This is required, if columns are changed.
* SQLite will drop the table and therefor all referenced data if we don't deactivate this. * SQLite will drop the table and all referenced data, if we don't deactivate this.
* *
* @return bool * @return bool
*/ */

View File

@@ -79,6 +79,14 @@ class Customer implements EntityWithMetaFields
*/ */
private $company; private $company;
/**
* @var string
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $vatId;
/** /**
* @var string * @var string
* *
@@ -272,6 +280,18 @@ class Customer implements EntityWithMetaFields
return $this->company; return $this->company;
} }
public function getVatId(): ?string
{
return $this->vatId;
}
public function setVatId(?string $vatId): Customer
{
$this->vatId = $vatId;
return $this;
}
public function setContact(?string $contact): Customer public function setContact(?string $contact): Customer
{ {
$this->contact = $contact; $this->contact = $contact;

View File

@@ -56,6 +56,14 @@ class InvoiceTemplate
*/ */
private $company; private $company;
/**
* @var string
*
* @ORM\Column(name="vat_id", type="string", length=50, nullable=true)
* @Assert\Length(max=50)
*/
private $vatId;
/** /**
* @var string * @var string
* *
@@ -63,6 +71,13 @@ class InvoiceTemplate
*/ */
private $address; private $address;
/**
* @var string
*
* @ORM\Column(name="contact", type="text", nullable=true)
*/
private $contact;
/** /**
* @var int * @var int
* *
@@ -109,6 +124,13 @@ class InvoiceTemplate
*/ */
private $paymentTerms; private $paymentTerms;
/**
* @var string
*
* @ORM\Column(name="payment_details", type="text", nullable=true)
*/
private $paymentDetails;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -234,6 +256,42 @@ class InvoiceTemplate
return $this; return $this;
} }
public function getVatId(): ?string
{
return $this->vatId;
}
public function setVatId(?string $vatId): InvoiceTemplate
{
$this->vatId = $vatId;
return $this;
}
public function getContact(): ?string
{
return $this->contact;
}
public function setContact(?string $contact): InvoiceTemplate
{
$this->contact = $contact;
return $this;
}
public function getPaymentDetails(): ?string
{
return $this->paymentDetails;
}
public function setPaymentDetails(?string $paymentDetails): InvoiceTemplate
{
$this->paymentDetails = $paymentDetails;
return $this;
}
/** /**
* @return string * @return string
*/ */

View File

@@ -71,9 +71,9 @@ final class MenuSubscriber implements EventSubscriberInterface
} }
if ($auth->isGranted('view_invoice')) { if ($auth->isGranted('view_invoice')) {
$menu->addItem( $invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice')) $invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create']);
); $menu->addItem($invoice);
} }
if ($auth->isGranted('create_export')) { if ($auth->isGranted('create_export')) {

View File

@@ -56,6 +56,10 @@ class CustomerEditForm extends AbstractType
'label' => 'label.company', 'label' => 'label.company',
'required' => false, 'required' => false,
]) ])
->add('vatId', TextType::class, [
'label' => 'label.vat_id',
'required' => false,
])
->add('contact', TextType::class, [ ->add('contact', TextType::class, [
'label' => 'label.contact', 'label' => 'label.contact',
'required' => false, 'required' => false,

View File

@@ -40,14 +40,26 @@ class InvoiceTemplateForm extends AbstractType
->add('company', TextType::class, [ ->add('company', TextType::class, [
'label' => 'label.company', 'label' => 'label.company',
]) ])
->add('vatId', TextType::class, [
'label' => 'label.vat_id',
'required' => false,
])
->add('address', TextareaType::class, [ ->add('address', TextareaType::class, [
'label' => 'label.address', 'label' => 'label.address',
'required' => false, 'required' => false,
]) ])
->add('contact', TextareaType::class, [
'label' => 'label.contact',
'required' => false,
])
->add('paymentTerms', TextareaType::class, [ ->add('paymentTerms', TextareaType::class, [
'label' => 'label.payment_terms', 'label' => 'label.payment_terms',
'required' => false, 'required' => false,
]) ])
->add('paymentDetails', TextareaType::class, [
'label' => 'label.invoice_bank_account',
'required' => false,
])
->add('dueDays', TextType::class, [ ->add('dueDays', TextType::class, [
'label' => 'label.due_days', 'label' => 'label.due_days',
]) ])
@@ -71,6 +83,11 @@ class InvoiceTemplateForm extends AbstractType
'csrf_protection' => true, 'csrf_protection' => true,
'csrf_field_name' => '_token', 'csrf_field_name' => '_token',
'csrf_token_id' => 'admin_invoice_template', 'csrf_token_id' => 'admin_invoice_template',
'attr' => [
'data-form-event' => 'kimai.invoiceTemplateUpdate',
'data-msg-success' => 'action.update.success',
'data-msg-error' => 'action.update.error',
],
]); ]);
} }
} }

View File

@@ -41,7 +41,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
]); ]);
$builder->add('create', SubmitType::class, [ $builder->add('create', SubmitType::class, [
'label' => 'button.print', 'label' => 'button.print',
'attr' => ['formtarget' => 'invoice'], 'attr' => ['formtarget' => '_blank'],
]); ]);
$builder->add('preview', SubmitType::class, [ $builder->add('preview', SubmitType::class, [
'label' => 'button.preview', 'label' => 'button.preview',

View File

@@ -50,6 +50,7 @@ class InvoiceCalculatorType extends AbstractType
return $renderer; return $renderer;
}, },
'translation_domain' => 'invoice-calculator', 'translation_domain' => 'invoice-calculator',
'search' => false,
]); ]);
} }

View File

@@ -50,6 +50,7 @@ class InvoiceNumberGeneratorType extends AbstractType
return $renderer; return $renderer;
}, },
'translation_domain' => 'invoice-numbergenerator', 'translation_domain' => 'invoice-numbergenerator',
'search' => false,
]); ]);
} }

View File

@@ -56,6 +56,7 @@ class InvoiceRendererType extends AbstractType
}, },
'translation_domain' => 'invoice-renderer', 'translation_domain' => 'invoice-renderer',
'docu_chapter' => 'invoices.html', 'docu_chapter' => 'invoices.html',
'search' => false,
]); ]);
} }

View File

@@ -25,7 +25,7 @@ class YesNoType extends AbstractType
{ {
$resolver->setDefaults([ $resolver->setDefaults([
'value' => true, 'value' => true,
'false_values' => [null, 0, false, 'false'], 'false_values' => [null, 0, false, 'false', '', '0'],
'required' => false, 'required' => false,
]); ]);
} }

View File

@@ -114,6 +114,9 @@ trait RendererTrait
'template.title' => $model->getTemplate()->getTitle(), 'template.title' => $model->getTemplate()->getTitle(),
'template.payment_terms' => $model->getTemplate()->getPaymentTerms(), 'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
'template.due_days' => $model->getTemplate()->getDueDays(), 'template.due_days' => $model->getTemplate()->getDueDays(),
'template.vat_id' => $model->getTemplate()->getVatId(),
'template.contact' => $model->getTemplate()->getContact(),
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()), 'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'), 'query.day' => $model->getQuery()->getBegin()->format('d'),
@@ -164,6 +167,7 @@ trait RendererTrait
'customer.name' => $customer->getName(), 'customer.name' => $customer->getName(),
'customer.contact' => $customer->getContact(), 'customer.contact' => $customer->getContact(),
'customer.company' => $customer->getCompany(), 'customer.company' => $customer->getCompany(),
'customer.vat' => $customer->getVatId(),
'customer.number' => $customer->getNumber(), 'customer.number' => $customer->getNumber(),
'customer.country' => $customer->getCountry(), 'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(), 'customer.homepage' => $customer->getHomepage(),

View File

@@ -15,35 +15,23 @@ use App\Invoice\RendererInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Twig\Environment; use Twig\Environment;
class TwigRenderer implements RendererInterface final class TwigRenderer implements RendererInterface
{ {
/** /**
* @var Environment * @var Environment
*/ */
protected $twig; private $twig;
/**
* @param Environment $twig
*/
public function __construct(Environment $twig) public function __construct(Environment $twig)
{ {
$this->twig = $twig; $this->twig = $twig;
} }
/**
* @param InvoiceDocument $document
* @return bool
*/
public function supports(InvoiceDocument $document): bool public function supports(InvoiceDocument $document): bool
{ {
return stripos($document->getFilename(), '.twig') !== false; return stripos($document->getFilename(), '.html.twig') !== false;
} }
/**
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response public function render(InvoiceDocument $document, InvoiceModel $model): Response
{ {
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [ $content = $this->twig->render('@invoice/' . basename($document->getFilename()), [

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* New Vat ID columns and invoice template improvements
*
* @version 1.6
*/
final class Version20191116110124 extends AbstractMigration
{
public function getDescription(): string
{
return 'New Vat ID columns and invoice template improvements';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$customers = $schema->getTable('kimai2_customers');
$customers->addColumn('vat_id', 'string', ['length' => 50, 'notnull' => false]);
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->addColumn('vat_id', 'string', ['length' => 50, 'notnull' => false, 'default' => null]);
$invoiceTemplates->addColumn('contact', 'text', ['notnull' => false, 'default' => null]);
$invoiceTemplates->addColumn('payment_details', 'text', ['notnull' => false, 'default' => null]);
$this->addSql("UPDATE kimai2_invoice_templates SET renderer = 'default' WHERE renderer IN ('export', 'open-spreadsheet', 'spreadsheet')");
}
public function down(Schema $schema): void
{
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->dropColumn('payment_details');
$invoiceTemplates->dropColumn('contact');
$invoiceTemplates->dropColumn('vat_id');
$customers = $schema->getTable('kimai2_customers');
$customers->dropColumn('vat_id');
}
}

View File

@@ -243,6 +243,8 @@ class CustomerRepository extends EntityRepository
$qb->expr()->orX( $qb->expr()->orX(
$qb->expr()->like('c.name', ':searchTerm'), $qb->expr()->like('c.name', ':searchTerm'),
$qb->expr()->like('c.comment', ':searchTerm'), $qb->expr()->like('c.comment', ':searchTerm'),
$qb->expr()->like('c.company', ':searchTerm'),
$qb->expr()->like('c.vatId', ':searchTerm'),
$qb->expr()->like('c.number', ':searchTerm'), $qb->expr()->like('c.number', ':searchTerm'),
$qb->expr()->like('c.contact', ':searchTerm'), $qb->expr()->like('c.contact', ':searchTerm'),
$qb->expr()->like('c.phone', ':searchTerm'), $qb->expr()->like('c.phone', ':searchTerm'),

View File

@@ -25,7 +25,14 @@
{{ form_row(form.comment) }} {{ form_row(form.comment) }}
<fieldset> <fieldset>
{#<legend>Company data</legend>#} {#<legend>Company data</legend>#}
{{ form_row(form.company) }} <div class="row">
<div class="col-md-8">
{{ form_row(form.company) }}
</div>
<div class="col-md-4">
{{ form_row(form.vatId) }}
</div>
</div>
{{ form_row(form.address) }} {{ form_row(form.address) }}
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">

View File

@@ -10,56 +10,66 @@
</div> </div>
</div> </div>
<div class="row invoice-info"> <div class="row">
<div class="col-sm-4 invoice-col"> <div class="col-sm-5">
{{ 'invoice.from'|trans }} {{ 'invoice.from'|trans }}
<address contenteditable="true"> <address contenteditable="true">
<strong>{{ model.template.company }}</strong><br> <strong>{{ model.template.company }}</strong><br>
{{ model.template.address|trim|nl2br }} {{ model.template.address|trim|nl2br }}
{% if model.template.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans }}:
{{ model.template.vatId }}
{% endif %}
</address> </address>
</div> </div>
<div class="col-sm-4 invoice-col"> <div class="col-sm-2"></div>
<div class="col-sm-5">
{% set customerPhone = model.customer.phone|default(model.customer.mobile) %} {% set customerPhone = model.customer.phone|default(model.customer.mobile) %}
{{ 'invoice.to'|trans }} {{ 'invoice.to'|trans }}
<address contenteditable="true"> <address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br> <strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }} {{ model.customer.address|nl2br }}
{% if model.customer.phone is not empty %} {% if model.customer.vatId is not empty %}
<br> <br>
{{ 'label.phone'|trans }}: {{ model.customer.phone }} {{ 'label.vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %} {% endif %}
{% if model.customer.email is not empty %}
<br>
{{ 'label.email'|trans }}: {{ model.customer.email }}
{% endif %}
</address>
</div>
<div class="col-sm-4 invoice-col">
<p contenteditable="true">
<b>{{ 'invoice.number'|trans }}: {{ model.numberGenerator.invoiceNumber }}</b>
</p>
<p contenteditable="true">
<b>{{ 'invoice.due_days'|trans }}:</b> {{ model.dueDate|date_short }}
{% if model.customer.number is not empty %} {% if model.customer.number is not empty %}
<br><b>{{ 'label.number'|trans }}:</b> {{ model.customer.number }} <br>
{{ 'label.number'|trans }}: {{ model.customer.number }}
{% endif %} {% endif %}
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %} {% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<br><b>{{ 'label.orderNumber'|trans }}:</b> {{ model.query.project.orderNumber }} <br>
{{ 'label.orderNumber'|trans }}: {{ model.query.project.orderNumber }}
{% endif %} {% endif %}
</p> </address>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
<div class="col-sm-7"></div>
</div>
<div class="row invoice-items">
<div class="col-xs-12 table-responsive"> <div class="col-xs-12 table-responsive">
<table class="table table-striped"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>{{ 'label.date'|trans }}</th> <th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.description'|trans }}</th> <th>{{ 'label.description'|trans }}</th>
<th>{{ 'label.unit_price'|trans }}</th> <th>{{ 'label.unit_price'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th> <th class="text-center">{{ 'label.hours'|trans }}</th>
<th>{{ 'label.total_rate'|trans }}</th> <th class="text-right">{{ 'label.total_rate'|trans }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -74,7 +84,7 @@
{% set rate = app.user.getPreferenceValue('hourly_rate') %} {% set rate = app.user.getPreferenceValue('hourly_rate') %}
{% endif %} {% endif %}
<tr> <tr>
<td>{{ entry.begin|date_short }}</td> <td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
<td contenteditable="true"> <td contenteditable="true">
{% if entry.description is not empty %} {% if entry.description is not empty %}
{{ entry.description|nl2br }} {{ entry.description|nl2br }}
@@ -82,48 +92,56 @@
{{ entry.activity.name }} / {{ entry.project.name }} {{ entry.activity.name }} / {{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td contenteditable="true">{{ rate|money(model.calculator.currency) }}</td> <td nowrap class="text-nowrap" contenteditable="true">{{ rate|money(model.calculator.currency) }}</td>
<td>{{ duration }}</td> <td nowrap class="text-nowrap text-center">{{ duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td> <td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)
</td>
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-right">
<strong>{{ 'invoice.total'|trans }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
</td>
</tr>
</tfoot>
</table> </table>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-6"> <div class="col-xs-12">
{% if model.template.paymentTerms is not empty %} {% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p> <p contenteditable="true" class="paymentTerms">
{{ model.template.paymentTerms|nl2br|md2html }}
<p class="text-muted well well-sm no-shadow" contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }}
</p> </p>
{% endif %} {% endif %}
</div> </div>
<div class="col-xs-6">
<p class="lead" contenteditable="true">{{ 'invoice.due_days'|trans }} {{ model.dueDate|date_short }}</p>
<div class="table-responsive">
<table class="table">
<tbody>
<tr>
<th style="width:50%">{{ 'invoice.subtotal'|trans }}</th>
<td>{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)</th>
<td>{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.total'|trans }}</th>
<td>{{ model.calculator.total|money(model.calculator.currency) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans }}</strong>: {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>
{% endblock %} {% endblock %}

View File

@@ -4,30 +4,18 @@
{% block invoice %} {% block invoice %}
{# You can overwrite these settings in your config/packages/local.yaml under the key "twig.globals.company" #} {% set signature = null %}
{% set company = company|default({ {# You can overwrite these settings in your config/packages/local.yaml under the key "twig.globals.company" - previously '/build/images/signature.png' #}
name: 'Kimai Inc.', {% set company = company|default({signature: null}) %}
homepage: 'www.kimai.org', {% if company.signature is defined and company.signature is not empty %}
email: 'kimai@example.com', {% set signature = company.signature %}
phone: '0123-4567890', {% endif %}
tax_number: 'YourTaxNumber',
signature: '/build/images/signature.png',
address: 'Kimai Inc.
Example road 42
D 12345 City
',
bank_account: '
Kimai Inc.
IBAN: DE00 0000 0000 0000 0000 00
BIC: XXXXXXXX (a bank name)
'
}) %}
<div class="row" id="freelancer-invoice" style="page:invoiceFreelancer;"> <div class="row" id="freelancer-invoice">
<div class="col-xs-12"> <div class="col-xs-12">
<header> <header>
<address> <address>
<p contenteditable="true">{{ model.template.address }}</p> <p contenteditable="true">{{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}</p>
</address> </address>
</header> </header>
<article class="address"> <article class="address">
@@ -36,6 +24,10 @@
<p> <p>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br> <strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }} {{ model.customer.address|nl2br }}
{% if model.customer.vatId is not empty %}
<br>
{{ 'label.vat_id'|trans }}: {{ model.customer.vatId }}
{% endif %}
</p> </p>
</address> </address>
<table class="meta"> <table class="meta">
@@ -59,6 +51,14 @@
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% if model.template.vatId is not empty %}
<tr>
<th>{{ 'label.vat_id'|trans }}:</th>
<td>
{{ model.template.vatId }}
</td>
</tr>
{% endif %}
</table> </table>
</article> </article>
<article> <article>
@@ -118,44 +118,44 @@
</table> </table>
</article> </article>
{% if model.template.paymentTerms is not empty %} {% if model.template.paymentTerms is not empty %}
<article class="footer"> <article class="paymentTerms">
<p contenteditable="true"> <p contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }} {{ model.template.paymentTerms|nl2br|md2html }}
</p> </p>
{% if company.signature is defined %} {% if signature is not empty %}
<img src="{{ company.signature }}"> <img src="{{ signature }}">
{% endif %} {% endif %}
</article> </article>
{% endif %} {% endif %}
<footer> <footer class="footer">
<table> <div class="row">
<thead> <div class="col-sm-4" contenteditable="true">
<tr> <p>
<th>{{ 'label.address'|trans }}</th> <strong>{{ 'label.address'|trans }}</strong>
<th>{{ 'label.invoice_bank_account'|trans }}</th> </p>
<th>{{ 'label.contact'|trans }}</th> <p>
</tr> {{ model.template.address|nl2br }}
</thead> </p>
<tbody> </div>
<tr> <div class="col-sm-4 text-center" contenteditable="true">
<td contenteditable="true"> <p>
{{ company.address|nl2br }} <strong>{{ 'label.invoice_bank_account'|trans }}</strong>
{{ 'label.invoice_tax_number'|trans }} {{ company.tax_number }} </p>
</td> <p>
<td contenteditable="true"> {{ model.template.paymentDetails|nl2br }}
{{ company.bank_account|nl2br }} </p>
</td> </div>
<td contenteditable="true"> <div class="col-sm-4 text-right" contenteditable="true">
{{ company.phone }} <p>
<br/> <strong>{{ 'label.contact'|trans }}</strong>
{{ company.email }} </p>
<br/> <p>
{{ company.homepage }} {{ model.template.contact|nl2br }}
</td> </p>
</tr> </div>
</tbody> </div>
</table>
</footer> </footer>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -52,7 +52,7 @@
</div> </div>
</div> </div>
<div class="row"> <div class="row invoice-items">
<div class="col-xs-12 table-responsive"> <div class="col-xs-12 table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@@ -97,22 +97,21 @@
</div> </div>
</div> </div>
<div class="row" style="padding: 60px 0"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
{% if model.template.paymentTerms is not empty %} {% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p> <p class="lead">{{ 'label.payment_terms'|trans }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true"> <p class="text-muted well well-sm no-shadow" contenteditable="true" style="margin-bottom: 100px">
{{ model.template.paymentTerms|trim|nl2br }} {{ model.template.paymentTerms|trim|nl2br }}
</p> </p>
{% endif %} {% endif %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
<th style="width:50%;padding-bottom: 60px">{{ 'invoice.signature_user'|trans }}</th> <th style="padding-bottom: 60px">{{ 'invoice.signature_user'|trans }}</th>
</tr> </tr>
<tr> <tr>
<th>{{ 'invoice.signature_customer'|trans }}</th> <th>{{ 'invoice.signature_customer'|trans }}</th>

View File

@@ -3,9 +3,54 @@
{% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %} {% block page_title %}{{ 'admin_invoice_template.title'|trans }}{% endblock %}
{% block main %} {% block main %}
{{ include(app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig', { {% set formEditTemplate = app.request.xmlHttpRequest ? 'default/_form_modal.html.twig' : 'default/_form.html.twig' %}
{% set formOptions = {
'title': template.name|default('create'|trans), 'title': template.name|default('create'|trans),
'form': form, 'form': form,
'back': path('admin_invoice_template') 'back': path('admin_invoice_template')
}) }} } %}
{% embed formEditTemplate with formOptions %}
{% block form_body %}
{{ form_row(form.name) }}
{{ form_row(form.title) }}
<div class="row">
<div class="col-md-6">
{{ form_row(form.company) }}
</div>
<div class="col-md-6">
{{ form_row(form.vatId) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.address) }}
</div>
<div class="col-md-6">
{{ form_row(form.contact) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.paymentTerms) }}
</div>
<div class="col-md-6">
{{ form_row(form.paymentDetails) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.dueDays) }}
</div>
<div class="col-md-6">
{{ form_row(form.vat) }}
</div>
</div>
{{ form_row(form.renderer) }}
{{ form_row(form.calculator) }}
{{ form_row(form.numberGenerator) }}
{{ form_widget(form) }}
{% endblock %}
{% endembed %}
{% endblock %} {% endblock %}

View File

@@ -21,7 +21,7 @@
{% set tableName = 'invoice_template' %} {% set tableName = 'invoice_template' %}
{{ tables.data_table_header(tableName, columns) }} {{ tables.datatable_header(tableName, columns, null, {'reload': 'kimai.invoiceTemplateUpdate'}) }}
{% for entry in entries %} {% for entry in entries %}
<tr class="modal-ajax-form open-edit" data-href="{{ path('admin_invoice_template_edit', {'id' : entry.id}) }}"> <tr class="modal-ajax-form open-edit" data-href="{{ path('admin_invoice_template_edit', {'id' : entry.id}) }}">

View File

@@ -23,85 +23,89 @@ class CustomerTest extends TestCase
public function testDefaultValues() public function testDefaultValues()
{ {
$sut = new Customer(); $sut = new Customer();
$this->assertNull($sut->getId()); self::assertNull($sut->getId());
$this->assertNull($sut->getName()); self::assertNull($sut->getName());
$this->assertNull($sut->getNumber()); self::assertNull($sut->getNumber());
$this->assertNull($sut->getComment()); self::assertNull($sut->getComment());
$this->assertTrue($sut->getVisible()); self::assertTrue($sut->getVisible());
$this->assertNull($sut->getCompany()); self::assertNull($sut->getCompany());
$this->assertNull($sut->getContact()); self::assertNull($sut->getVatId());
$this->assertNull($sut->getAddress()); self::assertNull($sut->getContact());
$this->assertNull($sut->getCountry()); self::assertNull($sut->getAddress());
$this->assertEquals('EUR', $sut->getCurrency()); self::assertNull($sut->getCountry());
$this->assertEquals('EUR', Customer::DEFAULT_CURRENCY); self::assertEquals('EUR', $sut->getCurrency());
$this->assertNull($sut->getPhone()); self::assertEquals('EUR', Customer::DEFAULT_CURRENCY);
$this->assertNull($sut->getFax()); self::assertNull($sut->getPhone());
$this->assertNull($sut->getMobile()); self::assertNull($sut->getFax());
$this->assertNull($sut->getEmail()); self::assertNull($sut->getMobile());
$this->assertNull($sut->getHomepage()); self::assertNull($sut->getEmail());
$this->assertNull($sut->getTimezone()); self::assertNull($sut->getHomepage());
self::assertNull($sut->getTimezone());
$this->assertNull($sut->getFixedRate()); self::assertNull($sut->getFixedRate());
$this->assertNull($sut->getHourlyRate()); self::assertNull($sut->getHourlyRate());
$this->assertNull($sut->getColor()); self::assertNull($sut->getColor());
$this->assertEquals(0.0, $sut->getBudget()); self::assertEquals(0.0, $sut->getBudget());
$this->assertEquals(0, $sut->getTimeBudget()); self::assertEquals(0, $sut->getTimeBudget());
$this->assertInstanceOf(Collection::class, $sut->getMetaFields()); self::assertInstanceOf(Collection::class, $sut->getMetaFields());
$this->assertEquals(0, $sut->getMetaFields()->count()); self::assertEquals(0, $sut->getMetaFields()->count());
$this->assertNull($sut->getMetaField('foo')); self::assertNull($sut->getMetaField('foo'));
$this->assertInstanceOf(Collection::class, $sut->getTeams()); self::assertInstanceOf(Collection::class, $sut->getTeams());
$this->assertEquals(0, $sut->getTeams()->count()); self::assertEquals(0, $sut->getTeams()->count());
} }
public function testSetterAndGetter() public function testSetterAndGetter()
{ {
$sut = new Customer(); $sut = new Customer();
$this->assertInstanceOf(Customer::class, $sut->setName('foo-bar')); self::assertInstanceOf(Customer::class, $sut->setName('foo-bar'));
$this->assertEquals('foo-bar', $sut->getName()); self::assertEquals('foo-bar', $sut->getName());
$this->assertEquals('foo-bar', (string) $sut); self::assertEquals('foo-bar', (string) $sut);
$this->assertInstanceOf(Customer::class, $sut->setVisible(false)); self::assertInstanceOf(Customer::class, $sut->setVisible(false));
$this->assertFalse($sut->getVisible()); self::assertFalse($sut->getVisible());
$this->assertInstanceOf(Customer::class, $sut->setComment('hello world')); self::assertInstanceOf(Customer::class, $sut->setComment('hello world'));
$this->assertEquals('hello world', $sut->getComment()); self::assertEquals('hello world', $sut->getComment());
$this->assertInstanceOf(Customer::class, $sut->setColor('#fffccc')); self::assertInstanceOf(Customer::class, $sut->setColor('#fffccc'));
$this->assertEquals('#fffccc', $sut->getColor()); self::assertEquals('#fffccc', $sut->getColor());
$this->assertInstanceOf(Customer::class, $sut->setCompany('test company')); self::assertInstanceOf(Customer::class, $sut->setCompany('test company'));
$this->assertEquals('test company', $sut->getCompany()); self::assertEquals('test company', $sut->getCompany());
$this->assertInstanceOf(Customer::class, $sut->setContact('test contact')); self::assertInstanceOf(Customer::class, $sut->setContact('test contact'));
$this->assertEquals('test contact', $sut->getContact()); self::assertEquals('test contact', $sut->getContact());
$this->assertInstanceOf(Customer::class, $sut->setPhone('0123456789')); self::assertInstanceOf(Customer::class, $sut->setPhone('0123456789'));
$this->assertEquals('0123456789', $sut->getPhone()); self::assertEquals('0123456789', $sut->getPhone());
$this->assertInstanceOf(Customer::class, $sut->setFax('asdfghjkl')); self::assertInstanceOf(Customer::class, $sut->setFax('asdfghjkl'));
$this->assertEquals('asdfghjkl', $sut->getFax()); self::assertEquals('asdfghjkl', $sut->getFax());
$this->assertInstanceOf(Customer::class, $sut->setMobile('76576534')); self::assertInstanceOf(Customer::class, $sut->setMobile('76576534'));
$this->assertEquals('76576534', $sut->getMobile()); self::assertEquals('76576534', $sut->getMobile());
$this->assertInstanceOf(Customer::class, $sut->setEmail('test@example.com')); self::assertInstanceOf(Customer::class, $sut->setEmail('test@example.com'));
$this->assertEquals('test@example.com', $sut->getEmail()); self::assertEquals('test@example.com', $sut->getEmail());
$this->assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com')); self::assertInstanceOf(Customer::class, $sut->setHomepage('https://www.example.com'));
$this->assertEquals('https://www.example.com', $sut->getHomepage()); self::assertEquals('https://www.example.com', $sut->getHomepage());
$this->assertInstanceOf(Customer::class, $sut->setFixedRate(13.47)); self::assertInstanceOf(Customer::class, $sut->setFixedRate(13.47));
$this->assertEquals(13.47, $sut->getFixedRate()); self::assertEquals(13.47, $sut->getFixedRate());
$this->assertInstanceOf(Customer::class, $sut->setHourlyRate(99)); self::assertInstanceOf(Customer::class, $sut->setHourlyRate(99));
$this->assertEquals(99, $sut->getHourlyRate()); self::assertEquals(99, $sut->getHourlyRate());
$this->assertInstanceOf(Customer::class, $sut->setBudget(12345.67)); self::assertInstanceOf(Customer::class, $sut->setBudget(12345.67));
$this->assertEquals(12345.67, $sut->getBudget()); self::assertEquals(12345.67, $sut->getBudget());
$this->assertInstanceOf(Customer::class, $sut->setTimeBudget(937321)); self::assertInstanceOf(Customer::class, $sut->setTimeBudget(937321));
$this->assertEquals(937321, $sut->getTimeBudget()); self::assertEquals(937321, $sut->getTimeBudget());
self::assertInstanceOf(Customer::class, $sut->setVatId('ID 1234567890'));
self::assertEquals('ID 1234567890', $sut->getVatId());
} }
public function testMetaFields() public function testMetaFields()
@@ -109,7 +113,7 @@ class CustomerTest extends TestCase
$sut = new Customer(); $sut = new Customer();
$meta = new CustomerMeta(); $meta = new CustomerMeta();
$meta->setName('foo')->setValue('bar')->setType('test'); $meta->setName('foo')->setValue('bar')->setType('test');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta)); self::assertInstanceOf(Customer::class, $sut->setMetaField($meta));
self::assertEquals(1, $sut->getMetaFields()->count()); self::assertEquals(1, $sut->getMetaFields()->count());
$result = $sut->getMetaField('foo'); $result = $sut->getMetaField('foo');
self::assertSame($result, $meta); self::assertSame($result, $meta);
@@ -117,7 +121,7 @@ class CustomerTest extends TestCase
$meta2 = new CustomerMeta(); $meta2 = new CustomerMeta();
$meta2->setName('foo')->setValue('bar')->setType('test2'); $meta2->setName('foo')->setValue('bar')->setType('test2');
$this->assertInstanceOf(Customer::class, $sut->setMetaField($meta2)); self::assertInstanceOf(Customer::class, $sut->setMetaField($meta2));
self::assertEquals(1, $sut->getMetaFields()->count()); self::assertEquals(1, $sut->getMetaFields()->count());
self::assertCount(0, $sut->getVisibleMetaFields()); self::assertCount(0, $sut->getVisibleMetaFields());

View File

@@ -17,36 +17,63 @@ use PHPUnit\Framework\TestCase;
*/ */
class InvoiceTemplateTest extends TestCase class InvoiceTemplateTest extends TestCase
{ {
protected function assertIsFluent($actual) public function testDefaultValues()
{ {
$this->assertInstanceOf(InvoiceTemplate::class, $actual); $sut = new InvoiceTemplate();
self::assertNull($sut->getVatId());
self::assertNull($sut->getPaymentDetails());
self::assertNull($sut->getPaymentTerms());
self::assertNull($sut->getContact());
self::assertEquals(0.00, $sut->getVat());
self::assertNull($sut->getId());
self::assertNull($sut->getName());
self::assertNull($sut->getAddress());
self::assertNull($sut->getTitle());
self::assertNull($sut->getCompany());
self::assertEquals('default', $sut->getCalculator());
self::assertEquals('default', $sut->getNumberGenerator());
self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays());
} }
protected function getEntity() public function testSetNullForOptionalValues()
{ {
$entity = new InvoiceTemplate(); $sut = new InvoiceTemplate();
return $entity; self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
} }
public function testSetGetPaymentTerms() public function testSetterAndGetter()
{ {
$sut = $this->getEntity(); $sut = new InvoiceTemplate();
$this->assertNull($sut->getPaymentTerms()); self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms('foo bar'));
$this->assertIsFluent($sut->setPaymentTerms(null)); self::assertEquals('foo bar', $sut->getPaymentTerms());
$this->assertIsFluent($sut->setPaymentTerms(''));
$this->assertIsFluent($sut->setPaymentTerms('foo bar')); self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentDetails('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf'));
$this->assertEquals('foo bar', $sut->getPaymentTerms()); self::assertEquals('iuasdzgf isdfhvlksdjfbnvl ksdfbglisbdf', $sut->getPaymentDetails());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact('hello world'));
self::assertEquals('hello world', $sut->getContact());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVat(7.31));
self::assertEquals(7.31, $sut->getVat());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
self::assertEquals('1234567890', $sut->getVatId());
} }
public function testToString() public function testToString()
{ {
$sut = $this->getEntity(); $sut = new InvoiceTemplate();
$this->assertNull($sut->__toString()); self::assertInstanceOf(InvoiceTemplate::class, $sut->setName('a template name'));
$this->assertIsFluent($sut->setName('a template name')); self::assertEquals('a template name', $sut->__toString());
$this->assertEquals('a template name', $sut->__toString()); self::assertEquals('a template name', (string) $sut);
$this->assertEquals('a template name', (string) $sut);
} }
} }

View File

@@ -33,7 +33,7 @@ class CsvRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('export.csv'))); $this->assertTrue($sut->supports($this->getInvoiceDocument('export.csv', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
} }
@@ -51,7 +51,7 @@ class CsvRendererTest extends TestCase
{ {
/** @var CsvRenderer $sut */ /** @var CsvRenderer $sut */
$sut = $this->getAbstractRenderer(CsvRenderer::class); $sut = $this->getAbstractRenderer(CsvRenderer::class);
$document = $this->getInvoiceDocument('export.csv'); $document = $this->getInvoiceDocument('export.csv', true);
/** @var BinaryFileResponse $response */ /** @var BinaryFileResponse $response */
$response = $sut->render($document, $model); $response = $sut->render($document, $model);

View File

@@ -73,6 +73,9 @@ class DebugRendererTest extends TestCase
'template.title', 'template.title',
'template.payment_terms', 'template.payment_terms',
'template.due_days', 'template.due_days',
'template.vat_id',
'template.contact',
'template.payment_details',
'query.begin', 'query.begin',
'query.day', 'query.day',
'query.end', 'query.end',
@@ -84,6 +87,7 @@ class DebugRendererTest extends TestCase
'customer.name', 'customer.name',
'customer.contact', 'customer.contact',
'customer.company', 'customer.company',
'customer.vat',
'customer.country', 'customer.country',
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',

View File

@@ -35,7 +35,7 @@ class OdsRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods'))); $this->assertTrue($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods', true)));
} }
public function getTestModel() public function getTestModel()
@@ -52,7 +52,7 @@ class OdsRendererTest extends TestCase
/** @var OdsRenderer $sut */ /** @var OdsRenderer $sut */
$sut = $this->getAbstractRenderer(OdsRenderer::class); $sut = $this->getAbstractRenderer(OdsRenderer::class);
$model = $this->getInvoiceModel(); $model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('open-spreadsheet.ods'); $document = $this->getInvoiceDocument('open-spreadsheet.ods', true);
/** @var BinaryFileResponse $response */ /** @var BinaryFileResponse $response */
$response = $sut->render($document, $model); $response = $sut->render($document, $model);

View File

@@ -47,10 +47,16 @@ trait RendererTestTrait
* @param string $filename * @param string $filename
* @return InvoiceDocument * @return InvoiceDocument
*/ */
protected function getInvoiceDocument(string $filename) protected function getInvoiceDocument(string $filename, bool $testOnly = false)
{ {
if (!$testOnly) {
return new InvoiceDocument(
new \SplFileInfo($this->getInvoiceTemplatePath() . $filename)
);
}
return new InvoiceDocument( return new InvoiceDocument(
new \SplFileInfo($this->getInvoiceTemplatePath() . $filename) new \SplFileInfo(__DIR__ . '/../templates/' . $filename)
); );
} }

View File

@@ -34,7 +34,7 @@ class XlsxRendererTest extends TestCase
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx'))); $this->assertTrue($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx', true)));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods'))); $this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
} }
@@ -52,7 +52,7 @@ class XlsxRendererTest extends TestCase
/** @var XlsxRenderer $sut */ /** @var XlsxRenderer $sut */
$sut = $this->getAbstractRenderer(XlsxRenderer::class); $sut = $this->getAbstractRenderer(XlsxRenderer::class);
$model = $this->getInvoiceModel(); $model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('spreadsheet.xlsx'); $document = $this->getInvoiceDocument('spreadsheet.xlsx', true);
/** @var BinaryFileResponse $response */ /** @var BinaryFileResponse $response */
$response = $sut->render($document, $model); $response = $sut->render($document, $model);

View File

@@ -25,10 +25,7 @@ class InvoiceDocumentRepositoryTest extends TestCase
protected static $defaultDocuments = [ protected static $defaultDocuments = [
'company.docx', 'company.docx',
'default.html.twig', 'default.html.twig',
'export.csv',
'freelancer.html.twig', 'freelancer.html.twig',
'open-spreadsheet.ods',
'spreadsheet.xlsx',
'timesheet.html.twig', 'timesheet.html.twig',
]; ];

View File

@@ -551,6 +551,10 @@
<source>label.vat</source> <source>label.vat</source>
<target>Umsatzsteuer</target> <target>Umsatzsteuer</target>
</trans-unit> </trans-unit>
<trans-unit id="label.vat_id">
<source>label.vat_id</source>
<target>Umsatzsteuer-ID</target>
</trans-unit>
<trans-unit id="label.contact"> <trans-unit id="label.contact">
<source>label.contact</source> <source>label.contact</source>
<target>Kontakt</target> <target>Kontakt</target>

View File

@@ -551,6 +551,10 @@
<source>label.vat</source> <source>label.vat</source>
<target>Tax</target> <target>Tax</target>
</trans-unit> </trans-unit>
<trans-unit id="label.vat_id">
<source>label.vat_id</source>
<target>VAT-ID</target>
</trans-unit>
<trans-unit id="label.contact"> <trans-unit id="label.contact">
<source>label.contact</source> <source>label.contact</source>
<target>Contact</target> <target>Contact</target>

Binary file not shown.

1
var/packages/.gitkeep Normal file
View File

@@ -0,0 +1 @@
This directory can store as Kimai plugin/bundle folder.