added freelancer invoice template (#254)

This commit is contained in:
Kevin Papst
2018-09-02 21:29:49 +02:00
committed by GitHub
parent ee4df36735
commit a636faa4ab
41 changed files with 1105 additions and 145 deletions

18
assets/app.js Normal file
View File

@@ -0,0 +1,18 @@
require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
require('./sass/app.scss');
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale-all');
require('fullcalendar/dist/fullcalendar.min.css');
// ------ for charts ------
require('chart.js/dist/Chart.min');
// ------ Kimai itself ------
require('./js/kimai.js');
require('./js/datatable.js');
require('./js/toolbar.js');
require('./images/default_avatar.png');
require('./images/signature.png');

View File

@@ -1,36 +0,0 @@
/*
* 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.
*/
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
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;
}

BIN
assets/images/signature.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,19 +0,0 @@
require('../../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
require('../css/app.scss');
require('../css/datatable.scss');
require('../css/calendar.scss');
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale-all');
require('fullcalendar/dist/fullcalendar.min.css');
// ------ for charts ------
require('chart.js/dist/Chart.min');
// ------ Kimai itself ------
require('./kimai.js');
require('./datatable.js');
require('./toolbar.js');
require('../images/default_avatar.png');

299
assets/sass/invoice.scss Normal file
View File

@@ -0,0 +1,299 @@
/*
* 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.
*/
.invoice {
margin: 0;
}
@media (min-width: $screen-sm-min) {
.invoice {
margin: 10px 25px;
}
}
.page-header-small{
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: 16px/1 'Open Sans', sans-serif;
box-sizing: border-box;
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 {
}
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;
}
article.address {
margin-bottom: 1.5em;
}
article.footer {
margin-top: 60px;
}
/* article */
article, article address, table.meta, table.inventory {
margin: 2em 0 2em;
}
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: 90%;
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;
width: 20%;
}
/* footer */
footer {
margin-top: 50px;
}
footer table {
border-top: 1px solid #000;
font-size: 0.8em;
padding-top: 5px;
}
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, .invoice, .wrapper, #freelancer-invoice {
margin: 0;
padding: 0;
}
#freelancer-invoice {
margin: 1em 2em;
span:empty {
display: none;
}
.add, .cut {
display: none;
}
}
}

View File

@@ -36,8 +36,9 @@ kimai:
# All configs related to the invoice administration
invoice:
renderer:
default: 'App\Controller\InvoiceController::invoiceAction'
timesheet: 'App\Controller\InvoiceController::timesheetAction'
default: 'App\Controller\InvoicePrintController::printInvoice'
timesheet: 'App\Controller\InvoicePrintController::printTimesheet'
freelancer: 'App\Controller\InvoicePrintController::printFreelancer'
calculator:
default: 'App\Invoice\DefaultCalculator'
short: 'App\Invoice\ShortInvoiceCalculator'

View File

@@ -17,7 +17,7 @@
<env name="DATABASE_PREFIX" value="kimai2_" force="true"/>
<!-- define your env variables for the test env here -->
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak_vendors"/>
<env name="SYMFONY_DEPRECATIONS_HELPER" value="weak"/>
<!-- ###+ doctrine/doctrine-bundle ### -->
<env name="DATABASE_URL" value="sqlite:///var/data/kimai_test.sqlite" force="true"/>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,7 +1,7 @@
{
"build/app.js": "/build/app.js?a67489c4fc07f30e6f86",
"build/app.css": "/build/app.css?7ddba25b9e0ab7dec9690f128f8691c0",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/app.js": "/build/app.js?ba9725f5267413a18701",
"build/app.css": "/build/app.css?68655639f068cc8b6eadb4170698a2c5",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",
"build/images/fa-solid-900.svg": "/build/images/fa-solid-900.svg?666a82cb",
@@ -13,8 +13,8 @@
"build/fonts/fa-brands-400.woff": "/build/fonts/fa-brands-400.woff?e9f83339",
"build/fonts/glyphicons-halflings-regular.woff2": "/build/fonts/glyphicons-halflings-regular.woff2?448c34a5",
"build/fonts/fa-solid-900.eot": "/build/fonts/fa-solid-900.eot?0b93480e",
"build/fonts/fa-solid-900.woff": "/build/fonts/fa-solid-900.woff?dfc040d5",
"build/images/blue.png": "/build/images/blue.png?96f8a905",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/fonts/fa-regular-400.woff2": "/build/fonts/fa-regular-400.woff2?930c1264",
"build/images/fa-regular-400.svg": "/build/images/fa-regular-400.svg?4e41caaf",
"build/fonts/fa-regular-400.ttf": "/build/fonts/fa-regular-400.ttf?cdd8fa76",
@@ -23,5 +23,6 @@
"build/fonts/fa-brands-400.woff2": "/build/fonts/fa-brands-400.woff2?66f625f1",
"build/fonts/glyphicons-halflings-regular.ttf": "/build/fonts/glyphicons-halflings-regular.ttf?e18bbf61",
"build/images/fa-brands-400.svg": "/build/images/fa-brands-400.svg?5da7e33c",
"build/images/default_avatar.png": "/build/images/default_avatar.png?fc197350"
"build/images/default_avatar.png": "/build/images/default_avatar.png?fc197350",
"build/images/signature.png": "/build/images/signature.png?5f58501e"
}

View File

@@ -92,23 +92,55 @@ class InvoiceController extends AbstractController
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var InvoiceQuery $query */
$query = $form->getData();
$query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER);
if (null !== $query->getCustomer()) {
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->timesheetRepository->findByQuery($query);
$entries = $queryBuilder->getQuery()->getResult();
}
$entries = $this->getEntries($query);
}
$model = $this->prepareModel($query, $entries);
$action = null;
if ($query->getTemplate() !== null) {
$action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer());
}
return $this->render('invoice/index.html.twig', [
'model' => $model,
'action' => $action,
'form' => $form->createView(),
]);
}
/**
* @param InvoiceQuery $query
* @return Timesheet[]
*/
protected function getEntries(InvoiceQuery $query)
{
if (null === $query->getCustomer()) {
return [];
}
$query->setResultType(TimesheetQuery::RESULT_TYPE_QUERYBUILDER);
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$queryBuilder = $this->timesheetRepository->findByQuery($query);
return $queryBuilder->getQuery()->getResult();
}
/**
* @param InvoiceQuery $query
* @param array $entries
* @return InvoiceModel
* @throws \Exception
*/
protected function prepareModel(InvoiceQuery $query, array $entries)
{
$model = new InvoiceModel();
$model->setQuery($query);
$model->setEntries($entries);
@@ -129,14 +161,9 @@ class InvoiceController extends AbstractController
$model->setTemplate($query->getTemplate());
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
$action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer());
}
return $this->render('invoice/index.html.twig', [
'model' => $model,
'action' => $action,
'form' => $form->createView(),
]);
return $model;
}
/**
@@ -229,6 +256,7 @@ class InvoiceController extends AbstractController
try {
$this->invoiceRepository->saveTemplate($template);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_template');
} catch (\Exception $ex) {
$this->flashError('action.update.error', ['%reason%' => $ex->getMessage()]);
@@ -241,28 +269,6 @@ class InvoiceController extends AbstractController
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function invoiceAction(InvoiceModel $model)
{
return $this->render('invoice/renderer/print.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function timesheetAction(InvoiceModel $model)
{
return $this->render('invoice/renderer/timesheet.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceQuery $query
* @return \Symfony\Component\Form\FormInterface

View File

@@ -0,0 +1,54 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Model\InvoiceModel;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
/**
* Controller used to print invoices.
*
* @Security("is_granted('ROLE_TEAMLEAD')")
*/
class InvoicePrintController extends AbstractController
{
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printInvoice(InvoiceModel $model)
{
return $this->render('invoice/renderer/invoice.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printTimesheet(InvoiceModel $model)
{
return $this->render('invoice/renderer/timesheet.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printFreelancer(InvoiceModel $model)
{
return $this->render('invoice/renderer/freelancer.html.twig', [
'model' => $model,
]);
}
}

View File

@@ -28,13 +28,15 @@ class InvoiceFixtures extends Fixture
*/
public function load(ObjectManager $manager)
{
$this->loadInvoiceTemplates($manager);
$this->loadInvoiceTemplate($manager);
$this->loadFreelancerTemplate($manager);
$this->loadTimesheetTemplate($manager);
}
/**
* @param ObjectManager $manager
*/
private function loadInvoiceTemplates(ObjectManager $manager)
private function loadInvoiceTemplate(ObjectManager $manager)
{
$faker = Factory::create();
@@ -44,7 +46,10 @@ class InvoiceFixtures extends Fixture
->setTitle('Your company name')
->setCompany($faker->company)
->setVat(19)
->setDueDays(14)
->setDueDays(30)
->setRenderer('default')
->setCalculator('default')
->setNumberGenerator('default')
->setPaymentTerms(
'I would like to thank you for your confidence and will gladly be there for you in the future.' .
PHP_EOL .
@@ -62,4 +67,68 @@ class InvoiceFixtures extends Fixture
$manager->persist($template);
$manager->flush();
}
/**
* @param ObjectManager $manager
*/
private function loadFreelancerTemplate(ObjectManager $manager)
{
$faker = Factory::create();
$template = new InvoiceTemplate();
$template
->setName('Freelancer')
->setTitle('Rechnung')
->setCompany($faker->company)
->setVat(19)
->setDueDays(14)
->setRenderer('freelancer')
->setCalculator('short')
->setNumberGenerator('default')
->setPaymentTerms(
'Bitte überweisen Sie den Gesamtbetrag innerhalb von 14 Tagen nach Erhalt der Rechnung auf das unten genannte Konto. Verwenden Sie bitte als Betreff Ihrer Überweisung die Rechnungsnummer.' .
PHP_EOL .
PHP_EOL .
'Ich bedanke mich für das entgegengebrachte Vertrauen. Gerne bin ich auch künftig für Sie da.' .
PHP_EOL .
PHP_EOL .
'Mit freundlichen Grüßen,' .
PHP_EOL .
'Max Müller'
)
->setAddress(
$faker->name . ' - ' . $faker->streetAddress . '-' . $faker->postcode . ' ' . $faker->city
)
;
$manager->persist($template);
$manager->flush();
}
/**
* @param ObjectManager $manager
*/
private function loadTimesheetTemplate(ObjectManager $manager)
{
$faker = Factory::create();
$template = new InvoiceTemplate();
$template
->setName('Timesheet')
->setTitle('Stundenzettel')
->setCompany($faker->company)
->setVat(19)
->setDueDays(14)
->setRenderer('timesheet')
->setCalculator('default')
->setNumberGenerator('default')
->setPaymentTerms('')
->setAddress(
$faker->name . ' - ' . $faker->streetAddress . '-' . $faker->postcode . ' ' . $faker->city
)
;
$manager->persist($template);
$manager->flush();
}
}

View File

@@ -58,4 +58,18 @@ interface CalculatorInterface
* @return string
*/
public function getCurrency(): string;
/**
* Returns the percentage for the value-added tax (VAT) calculation.
*
* @return float
*/
public function getVat(): ?float;
/**
* Returns the total amount of worked time in seconds.
*
* @return int
*/
public function getTimeWorked(): int;
}

View File

@@ -62,7 +62,7 @@ class DefaultCalculator implements CalculatorInterface
/**
* @return float
*/
public function getVat()
public function getVat(): ?float
{
return $this->model->getTemplate()->getVat();
}
@@ -97,4 +97,19 @@ class DefaultCalculator implements CalculatorInterface
{
return $this->model->getCustomer()->getCurrency();
}
/**
* Returns the total amount of worked time in seconds.
*
* @return int
*/
public function getTimeWorked(): int
{
$time = 0;
foreach ($this->model->getEntries() as $entry) {
$time += $entry->getDuration();
}
return $time;
}
}

View File

@@ -27,11 +27,16 @@ class ShortInvoiceCalculator extends DefaultCalculator
foreach ($this->model->getEntries() as $entry) {
$timesheet->setRate($timesheet->getRate() + $entry->getRate());
$timesheet->setDuration($timesheet->getDuration() + $entry->getDuration());
$timesheet->setBegin($entry->getBegin());
if (null === $timesheet->getActivity()) {
$timesheet->setActivity($entry->getActivity());
$timesheet->setEnd($entry->getEnd());
}
$timesheet->setBegin($entry->getBegin());
}
if (null !== $this->model->getQuery()->getActivity()) {
$timesheet->setDescription($this->model->getQuery()->getActivity()->getName());
} elseif (null !== $this->model->getQuery()->getProject()) {
$timesheet->setDescription($this->model->getQuery()->getProject()->getName());
}
return [$timesheet];

View File

@@ -34,7 +34,7 @@ class InvoiceModel
/**
* @var Timesheet[]
*/
protected $entries;
protected $entries = [];
/**
* @var InvoiceTemplate

View File

@@ -37,6 +37,7 @@ class Markdown
public function toHtml(string $text, bool $safe = true): string
{
$this->parser->setSafeMode($safe);
return $this->parser->text($text);
}
}

View File

@@ -14,7 +14,7 @@
</div>
<div class="box-footer">
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{% if back %}
{% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %}
</div>

View File

@@ -21,7 +21,7 @@
{% if model.entries is empty or action is empty %}
{{ widgets.callout('warning', 'invoice.select_filter') }}
{% else %}
{{ render(controller(action, {'model': model})) }}
{{ render(controller(action, {'model': model, 'appRequest': app.request})) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,147 @@
{% import "macros/widgets.html.twig" as widgets %}
{# You can overrwite these settings in your config/packages.local.yaml under the key "twig.globals.company" #}
{% set company = company|default({
name: 'Kimai Inc.',
homepage: 'www.kimai.org',
email: 'kimai@example.com',
phone: '0123-4567890',
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="col-xs-12">
<header>
<address>
<p contenteditable="true">{{ model.template.address }}</p>
</address>
</header>
<article class="address">
<h1>{{ 'invoice.to'|trans }}</h1>
<address>
<p>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
{{ model.customer.address|nl2br }}
</p>
</address>
<table class="meta">
<tr>
<th>{{ 'label.date'|trans }}:</th>
<td contenteditable="true">{{ model.invoiceDate|date_short }}</td>
</tr>
<tr>
<th>{{ 'invoice.service_date'|trans }}:</th>
<td><span contenteditable="true">{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}</td>
</tr>
<tr>
<th>{{ 'invoice.number'|trans }}:</th>
<td><span contenteditable="true">{{ model.numberGenerator.invoiceNumber }}</td>
</tr>
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<tr>
<th>{{ 'label.order_number'|trans }}</th>
<td contenteditable="true">
{{ model.query.project.orderNumber }}
</td>
</tr>
{% endif %}
</table>
</article>
<article>
<h2>{{ model.template.title|default('invoice_renderer.timesheet'|trans) }}</h2>
<p contenteditable="true">
{{ 'label.invoice_salutation'|trans|nl2br }}
</p>
<table class="inventory">
<thead>
<tr>
<th>{{ 'label.description'|trans }}</th>
<th>{{ 'invoice.unit_price'|trans }}</th>
<th>{{ 'invoice.amount'|trans }}</th>
<th>{{ 'invoice.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
<tr>
<td contenteditable="true">{{ entry.description }}</td>
<td contenteditable="true">{{ app.user.getPreferenceValue('hourly_rate')|money(model.calculator.currency) }}</td>
<td contenteditable="true">{{ entry.duration|duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="balance">
<tr>
<th>{{ '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 class="total">{{ 'invoice.total'|trans }}</th>
<td class="total">{{ model.calculator.total|money(model.calculator.currency) }}</td>
</tr>
</table>
</article>
{% if model.template.paymentTerms is not empty %}
<article class="footer">
<p contenteditable="true">
{{ model.template.paymentTerms|trim|nl2br }}
</p>
{% if company.signature is defined %}
<img src="{{ company.signature }}">
{% endif %}
</article>
{% endif %}
<footer>
<table>
<thead>
<tr>
<th>{{ 'label.address'|trans }}</th>
<th>{{ 'label.invoice_bank_account'|trans }}</th>
<th>{{ 'label.contact'|trans }}</th>
</tr>
</thead>
<tbody>
<tr>
<td contenteditable="true">
{{ company.address|nl2br }}
{{ 'label.invoice_tax_number'|trans }} {{ company.tax_number }}
</td>
<td contenteditable="true">
{{ company.bank_account|nl2br }}
</td>
<td contenteditable="true">
{{ company.phone }}
<br/>
{{ company.email }}
<br/>
{{ company.homepage }}
</td>
</tr>
</tbody>
</table>
</footer>
</div>
</div>
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>

View File

@@ -51,21 +51,23 @@
<div class="col-xs-12 table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.activity'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
<th>{{ 'label.rate'|trans }}</th>
</tr>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.activity'|trans }}</th>
<th>{{ 'invoice.unit_price'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
<th>{{ 'invoice.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td>{{ entry.duration|duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
<tr>
<td>{{ entry.begin|date_short }}</td>
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td contenteditable="true">{{ app.user.getPreferenceValue('hourly_rate')|money(model.calculator.currency) }}</td>
<td>{{ entry.duration|duration }}</td>
<td>{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -109,7 +111,7 @@
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;" style="margin-right: 5px;">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>

View File

@@ -23,13 +23,13 @@
</tr>
<tr>
<th style="width:50%">{{ 'label.date'|trans }}</th>
<td contenteditable="true">{{ model.invoiceDate|month_name|trans }} {{ model.invoiceDate|date('Y') }}</td>
<td contenteditable="true">{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}</td>
</tr>
<tr>
<th>{{ 'label.customer'|trans }}</th>
<td contenteditable="true">
{% if model.customer.number is not empty %}[{{ model.customer.number }}]{% endif %}
{{ model.customer.name }} / {{ model.customer.contact }}
{{ model.customer.name }}{% if model.customer.contact is not empty %} / {{ model.customer.contact }}{% endif %}
</td>
</tr>
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
@@ -63,6 +63,13 @@
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<th></th>
<th>{{ 'invoice.total_working_time'|trans }}</th>
<th>{{ model.calculator.timeWorked|duration }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
@@ -95,7 +102,7 @@
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;" style="margin-right: 5px;">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>

View File

@@ -63,6 +63,59 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertHasDataTable($client);
}
public function testCreateTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/invoice/template/create');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('form[name=invoice_template_form]')->form();
$client->submit($form, [
'invoice_template_form' => [
'name' => 'Test',
'title' => 'Test invoice template',
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/invoice/template'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
}
public function testEditTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/template/1/edit?page=1');
$form = $client->getCrawler()->filter('form[name=invoice_template_form]')->form();
$client->submit($form, [
'invoice_template_form' => [
'name' => 'Test 2!',
'title' => 'Test invoice template',
'company' => 'Company name',
'renderer' => 'default',
'calculator' => 'default',
'numberGenerator' => 'default',
]
]);
$this->assertIsRedirect($client, $this->createUrl('/invoice/template'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
}
public function testDeleteTemplateAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\Event\DashboardEvent;

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code.
*/
namespace App\Tests\Voter;
namespace App\Tests\EventSubscriber;
use App\Entity\User;
use App\EventSubscriber\RegistrationSubscriber;

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Invoice\DefaultCalculator;
use App\Model\InvoiceModel;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\DefaultCalculator
*/
class DefaultCalculatorTest extends TestCase
{
public function testEmptyModel()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$sut = new DefaultCalculator();
$sut->setModel($model);
$this->assertEquals(0, $sut->getTotal());
$this->assertEquals(0, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(0, $sut->getSubtotal());
$this->assertEquals(0, $sut->getTimeWorked());
$this->assertEquals([], $sut->getEntries());
}
public function testWithMultipleEntries()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setVat(19);
$timesheet = new Timesheet();
$timesheet->setDuration(3600);
$timesheet->setRate(293.27);
$timesheet2 = new Timesheet();
$timesheet2->setDuration(400);
$timesheet2->setRate(84);
$timesheet3 = new Timesheet();
$timesheet3->setDuration(1800);
$timesheet3->setRate(111.11);
$entries = [$timesheet, $timesheet2, $timesheet3];
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$sut = new DefaultCalculator();
$sut->setModel($model);
$this->assertEquals(581.17, $sut->getTotal());
$this->assertEquals(19, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(488.38, $sut->getSubtotal());
$this->assertEquals(5800, $sut->getTimeWorked());
$this->assertEquals($entries, $sut->getEntries());
}
}

View File

@@ -0,0 +1,124 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Invoice\ShortInvoiceCalculator;
use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\ShortInvoiceCalculator
*/
class ShortInvoiceCalculatorTest extends TestCase
{
public function testEmptyModel()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$query = new InvoiceQuery();
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
$sut = new ShortInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals(0, $sut->getTotal());
$this->assertEquals(0, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(0, $sut->getSubtotal());
$this->assertEquals(0, $sut->getTimeWorked());
$this->assertEquals(1, count($sut->getEntries()));
}
public function testWithMultipleEntries()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setVat(19);
$activity = new Activity();
$activity->setName('activity description');
$timesheet = new Timesheet();
$timesheet->setDuration(3600);
$timesheet->setRate(293.27);
$timesheet2 = new Timesheet();
$timesheet2->setDuration(400);
$timesheet2->setRate(84);
$timesheet3 = new Timesheet();
$timesheet3->setDuration(1800);
$timesheet3->setRate(111.11);
$entries = [$timesheet, $timesheet2, $timesheet3];
$query = new InvoiceQuery();
$query->setActivity($activity);
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$model->setQuery($query);
$sut = new ShortInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals(581.17, $sut->getTotal());
$this->assertEquals(19, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(488.38, $sut->getSubtotal());
$this->assertEquals(5800, $sut->getTimeWorked());
$this->assertEquals(1, count($sut->getEntries()));
/** @var Timesheet $result */
$result = $sut->getEntries()[0];
$this->assertEquals('activity description', $result->getDescription());
$this->assertEquals(488.38, $result->getRate());
$this->assertEquals(5800, $result->getDuration());
}
public function testDescriptionByProject()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setVat(19);
$project = new Project();
$project->setName('project description');
$query = new InvoiceQuery();
$query->setProject($project);
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries([]);
$model->setQuery($query);
$sut = new ShortInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals(1, count($sut->getEntries()));
/** @var Timesheet $result */
$result = $sut->getEntries()[0];
$this->assertEquals('project description', $result->getDescription());
}
}

View File

@@ -147,6 +147,63 @@ class DurationCalculatorTest extends TestCase
(clone $start)->setTime(14, 32, 52), // 14:33 => 2:18 (second duration will not be rounded)
8280
],
[
[
'default' => [
'days' => [$day],
'begin' => 0,
'end' => 0,
'duration' => 1,
],
'weekdays' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
'begin' => 0,
'end' => 0,
'duration' => 1,
],
],
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
0
],
[
[
'default' => [
'days' => [$day],
'begin' => 1,
'end' => 1,
'duration' => 1,
],
'weekdays' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
'begin' => 1,
'end' => 1,
'duration' => 1,
],
],
(clone $start)->setTime(12, 27, 00), // no diff, to test ...
(clone $start)->setTime(12, 27, 00), // ... that no rounding is applied
0
],
[
[
'default' => [
'days' => [$day],
'begin' => 0,
'end' => 0,
'duration' => 0,
],
'weekdays' => [
'days' => ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
'begin' => 0,
'end' => 0,
'duration' => 0,
],
],
(clone $start)->setTime(12, 27, 35), // no diff, to test ...
(clone $start)->setTime(12, 27, 35), // ... that no rounding is applied
0
],
];
}
}

View File

@@ -48,5 +48,4 @@ class MarkdownExtensionTest extends TestCase
$sut->timesheetContent("- test\n- foo\n\nfoo __bar__")
);
}
}

View File

@@ -730,6 +730,14 @@
<source>invoice.service_date</source>
<target>Leistungsdatum</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Vorschau</target>
</trans-unit>
<trans-unit id="invoice.preview_info">
<source>invoice.preview_info</source>
<target>Die folgenden Einträge werden in Ihrer Rechnung enthalten sein:</target>
</trans-unit>
<trans-unit id="invoice.amount">
<source>invoice.amount</source>
<target>Anzahl</target>
@@ -738,6 +746,10 @@
<source>invoice.total_rate</source>
<target>Gesamtpreis</target>
</trans-unit>
<trans-unit id="invoice.unit_price">
<source>invoice.unit_price</source>
<target>Einzelbetrag</target>
</trans-unit>
<trans-unit id="label.payment_terms">
<source>label.payment_terms</source>
<target>Zahlungsinformationen</target>
@@ -754,6 +766,10 @@
<source>invoice_renderer.timesheet</source>
<target>Stundenzettel</target>
</trans-unit>
<trans-unit id="invoice_renderer.freelancer">
<source>invoice_renderer.freelancer</source>
<target>Freiberufler</target>
</trans-unit>
<trans-unit id="label.invoice_number_generator">
<source>label.invoice_number_generator</source>
<target>Rechnungsnummern-Generator</target>
@@ -782,10 +798,30 @@
<source>invoice.signature_customer</source>
<target>Leistungsbestätigung: Datum / Name Kunde / Unterschrift</target>
</trans-unit>
<trans-unit id="invoice.total_working_time">
<source>invoice.total_working_time</source>
<target>Gesamtdauer</target>
</trans-unit>
<trans-unit id="label.order_number">
<source>label.order_number</source>
<target>Bestellnummer</target>
</trans-unit>
<trans-unit id="label.invoice_tax_number">
<source>label.invoice_tax_number</source>
<target>USt-IdNr.:</target>
</trans-unit>
<trans-unit id="label.invoice_bank_account">
<source>label.invoice_bank_account</source>
<target>Bankverbindung</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Sehr geehrte Damen und Herren,
für Ihren Auftrag bedanke ich mich und berechne für meine Leistungen:
</target>
</trans-unit>
<!--
Month names

View File

@@ -116,14 +116,6 @@
<target>User</target>
</trans-unit>
<!--
Footer
-->
<trans-unit id="footer.license">
<source>footer.license</source>
<target>All rights reserved.</target>
</trans-unit>
<!--
Error templates
-->
@@ -738,6 +730,14 @@
<source>invoice.service_date</source>
<target>Service date</target>
</trans-unit>
<trans-unit id="invoice.preview">
<source>invoice.preview</source>
<target>Preview</target>
</trans-unit>
<trans-unit id="invoice.preview_info">
<source>invoice.preview_info</source>
<target>The following entries will be included in your invoice:</target>
</trans-unit>
<trans-unit id="invoice.amount">
<source>invoice.amount</source>
<target>Quantity</target>
@@ -746,6 +746,10 @@
<source>invoice.total_rate</source>
<target>Total price</target>
</trans-unit>
<trans-unit id="invoice.unit_price">
<source>invoice.unit_price</source>
<target>Unit price</target>
</trans-unit>
<trans-unit id="label.payment_terms">
<source>label.payment_terms</source>
<target>Terms of payment</target>
@@ -762,6 +766,10 @@
<source>invoice_renderer.timesheet</source>
<target>Timesheet</target>
</trans-unit>
<trans-unit id="invoice_renderer.freelancer">
<source>invoice_renderer.freelancer</source>
<target>Freelancer</target>
</trans-unit>
<trans-unit id="label.invoice_number_generator">
<source>label.invoice_number_generator</source>
<target>Invoicenumber-Generator</target>
@@ -790,10 +798,30 @@
<source>invoice.signature_customer</source>
<target>Confirmation: Date / Name Customer / Signature</target>
</trans-unit>
<trans-unit id="invoice.total_working_time">
<source>invoice.total_working_time</source>
<target>Total duration</target>
</trans-unit>
<trans-unit id="label.order_number">
<source>label.order_number</source>
<target>Order number</target>
</trans-unit>
<trans-unit id="label.invoice_tax_number">
<source>label.invoice_tax_number</source>
<target>VAT no.:</target>
</trans-unit>
<trans-unit id="label.invoice_bank_account">
<source>label.invoice_bank_account</source>
<target>Bank account</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Ladies and gentlemen,
I thank you for your order and charge for my services:
</target>
</trans-unit>
<!--
Month names

View File

@@ -17,7 +17,7 @@ Encore
.enableVersioning(Encore.isProduction())
// generate only two files: app.js and app.css
.addEntry('app', './assets/js/app.js')
.addEntry('app', './assets/app.js')
// enable sass/scss parser
.enableSassLoader()