Release 2.0.4 (#3883)

* fix column data truncated
* calculate internal rate from user
* show internal rate in timesheet listing
* Fixed: responsivenss and size of report start page icons
* fix: name display in dropdowns (and added tests)
* translate reload button
* fix invoice date might be in the past
* fail safe customer name handling
* translate invoice_date and invoice_date help
* prevent URLs like start=null
* prevent to reload select twice
This commit is contained in:
Kevin Papst
2023-03-02 14:04:06 +01:00
committed by GitHub
parent a8b972f8a5
commit 3f09a2674b
32 changed files with 409 additions and 77 deletions

View File

@@ -416,9 +416,10 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
const targetSelect = document.getElementById(apiSelect.dataset['relatedSelect']);
// if the related target select does not exist, we do not need to load the related data
if (targetSelect === null) {
if (targetSelect === null || targetSelect.dataset['reloading'] === '1') {
return;
}
targetSelect.dataset['reloading'] = '1';
if (targetSelect.tomselect !== undefined) {
targetSelect.tomselect.disable();
@@ -441,6 +442,7 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
if (selectValue === undefined || selectValue === null || selectValue === '' || (Array.isArray(selectValue) && selectValue.length === 0)) {
if (apiSelect.dataset['emptyUrl'] === undefined) {
this._updateSelect(targetSelectId, {});
targetSelect.dataset['reloading'] = '0';
return;
}
newApiUrl = this._buildUrlWithFormFields(apiSelect.dataset['emptyUrl'], formPrefix);
@@ -454,6 +456,7 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
if (targetSelect.tomselect !== undefined) {
targetSelect.tomselect.enable();
}
targetSelect.dataset['reloading'] = '0';
targetSelect.disabled = false;
});
}
@@ -533,10 +536,16 @@ export default class KimaiFormSelect extends KimaiFormPlugin {
if (Array.isArray(newValue)) {
let urlParams = [];
for (let tmpValue of newValue) {
if (tmpValue === null) {
tmpValue = '';
}
urlParams.push(originalFieldName + '=' + tmpValue);
}
newApiUrl = newApiUrl.replace(item, urlParams.join('&'));
} else {
if (newValue === null) {
newValue = '';
}
newApiUrl = newApiUrl.replace(value, newValue);
}
}

View File

@@ -1040,11 +1040,6 @@ parameters:
count: 2
path: src/Controller/CalendarController.php
-
message: "#^Parameter \\#1 \\$filename of static method App\\\\Utils\\\\FileHelper\\:\\:convertToAsciiFilename\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#1 \\$name of class App\\\\Entity\\\\Team constructor expects string, string\\|null given\\.$#"
count: 1
@@ -3395,11 +3390,6 @@ parameters:
count: 1
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#1 \\$haystack of function stripos expects string, bool\\|float\\|int\\|string given\\.$#"
count: 2

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,10 +3,10 @@
"app": {
"js": [
"/build/runtime.f0079159.js",
"/build/app.46a6055b.js"
"/build/app.952a9f98.js"
],
"css": [
"/build/app.2c6c5920.css"
"/build/app.c429ff5b.css"
]
},
"export-pdf": {
@@ -63,8 +63,8 @@
},
"integrity": {
"/build/runtime.f0079159.js": "sha384-H22sAW1aTvyIPqvHOvGXWSWTxf0y6mptp+MsVmyXCfjx/WJjBbhX9gbUZ+qIuihV",
"/build/app.46a6055b.js": "sha384-4FR2SCr6YUmX48AWUg8rqFrfHSOTRj6tIqZJK/CjrNx0XV1km7nn2BCEVFNo0UkA",
"/build/app.2c6c5920.css": "sha384-qiubu7JONCsPTbVOb8bSJ4GYxioCwIUvlsI0usMmIUyoTxawn6jsjG7KK64iej5S",
"/build/app.952a9f98.js": "sha384-qK3Hcem6As5Jy3ZiVz2rlABsJUIvNMQ0Dq6RfRBalbq1l88/zFfE5ZRXVMYtLP73",
"/build/app.c429ff5b.css": "sha384-m7RJ9g2fW3tENrf+oi/FsH8wTu6fsjuhvpDztfqr+YjNN0AvCapER6yvmGkqwp+I",
"/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.c8ae95ad.js": "sha384-2eVY7MBiMQxo1vhfizU+fYfEZbz9bYUdzqxnpTQhxpYiLaxeSV4WnaObq2G7/Pks",

View File

@@ -1,6 +1,6 @@
{
"build/app.css": "/build/app.2c6c5920.css",
"build/app.js": "/build/app.46a6055b.js",
"build/app.css": "/build/app.c429ff5b.css",
"build/app.js": "/build/app.952a9f98.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.587575e7.js",
"build/invoice.css": "/build/invoice.3c80ee80.css",

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '2.0.3';
public const VERSION = '2.0.4';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 20003;
public const VERSION_ID = 20004;
/**
* The software name
*/

View File

@@ -241,7 +241,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
return substr($query->getName(), 0, 50);
}
protected function handleSearch(FormInterface $form, Request $request): bool
/**
* @param FormInterface $form
* @param Request $request
* @param array<string> $filterParams parameter names, which should not be saved (neither session, nor database)
* @return bool
* @throws \Exception
*/
protected function handleSearch(FormInterface $form, Request $request, array $filterParams = []): bool
{
$data = $form->getData();
if (!($data instanceof BaseQuery)) {
@@ -339,7 +346,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
}
// these should NEVER be saved
$filter = ['setDefaultQuery', 'removeDefaultQuery', 'performSearch'];
$filter = array_merge(['setDefaultQuery', 'removeDefaultQuery', 'performSearch'], $filterParams);
foreach ($filter as $name) {
if (isset($params[$name])) {
unset($params[$name]);

View File

@@ -365,11 +365,15 @@ final class CustomerController extends AbstractController
#[IsGranted('view', 'customer')]
public function downloadVCard(Customer $customer): Response
{
if ($customer->getName() === null || \strlen($customer->getName()) === 0) {
throw new \Exception('Customer name cannot be null');
}
$vcard = new VCard();
$contact = $customer->getContact() ?? $customer->getName();
$contact = $customer->getContact();
if ($contact === null || \strlen($contact) === 0) {
$contact = 'Unknown';
$contact = $customer->getName();
}
$contact = explode(' ', $contact);

View File

@@ -81,10 +81,13 @@ final class InvoiceController extends AbstractController
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
if ($this->handleSearch($form, $request)) {
if ($this->handleSearch($form, $request, ['invoiceDate'])) {
return $this->redirectToRoute('invoice');
}
// this can be deleted in the future, but for now invalid bookmarks exists, which contain an old invoice date
$query->setInvoiceDate($this->getDateTimeFactory()->createDateTime());
$models = [];
$total = 0;
$searched = false;
@@ -116,6 +119,8 @@ final class InvoiceController extends AbstractController
->add('template', InvoiceTemplateType::class)
->add('invoiceDate', DatePickerType::class, [
'required' => true,
'label' => 'invoice_date',
'help' => 'invoice_date.help'
])
->createView();
}

View File

@@ -88,6 +88,7 @@ abstract class TimesheetAbstractController extends AbstractController
if ($canSeeRate) {
$table->addColumn('hourlyRate', ['class' => 'text-end d-none text-nowrap']);
$table->addColumn('internalRate', ['class' => 'text-end text-nowrap']);
$table->addColumn('rate', ['class' => 'text-end text-nowrap']);
}
@@ -113,6 +114,7 @@ abstract class TimesheetAbstractController extends AbstractController
$page->setActionName($this->getActionName());
return $this->render('timesheet/index.html.twig', [
'view_rate' => $canSeeRate,
'page_setup' => $page,
'dataTable' => $table,
'action_single' => $this->getActionNameSingle(),

View File

@@ -102,7 +102,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* URL to the user avatar, will be auto-generated if empty
*/
#[ORM\Column(name: 'avatar', type: 'string', length: 255, nullable: true)]
#[Assert\Length(max: 255)]
#[Assert\Length(max: 255, groups: ['Profile'])]
#[Serializer\Expose]
#[Serializer\Groups(['User_Entity'])]
private ?string $avatar = null;

View File

@@ -46,7 +46,7 @@ final class InvoiceDocumentSubscriber extends AbstractActionsSubscriber
$event->addAction('download', ['url' => $this->path('admin_invoice_document_download', ['document' => $document->getId()])]);
if ($document->isTwig()) {
$event->addAction('Reload', ['url' => $this->path('admin_invoice_document_reload', ['document' => $document->getId()])]);
$event->addAction('reload', ['url' => $this->path('admin_invoice_document_reload', ['document' => $document->getId()])]);
}
if (!$inUse) {

View File

@@ -46,8 +46,13 @@ final class ActivityHelper
$name = str_replace(self::PATTERN_NAME, $activity->getName(), $name);
$name = str_replace(self::PATTERN_COMMENT, $activity->getComment() ?? '', $name);
$name = ltrim($name, self::SPACER);
$name = rtrim($name, self::SPACER);
while (str_starts_with($name, self::SPACER)) {
$name = substr($name, \strlen(self::SPACER));
}
while (str_ends_with($name, self::SPACER)) {
$name = substr($name, 0, -\strlen(self::SPACER));
}
if ($name === '' || $name === self::SPACER) {
$name = $activity->getName();

View File

@@ -50,8 +50,13 @@ final class CustomerHelper
$name = str_replace(self::PATTERN_NUMBER, $customer->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMPANY, $customer->getCompany() ?? '', $name);
$name = ltrim($name, self::SPACER);
$name = rtrim($name, self::SPACER);
while (str_starts_with($name, self::SPACER)) {
$name = substr($name, \strlen(self::SPACER));
}
while (str_ends_with($name, self::SPACER)) {
$name = substr($name, 0, -\strlen(self::SPACER));
}
if ($name === '' || $name === self::SPACER) {
$name = $customer->getName();

View File

@@ -12,9 +12,10 @@ namespace App\Form\Helper;
use App\Configuration\LocaleService;
use App\Configuration\SystemConfiguration;
use App\Entity\Project;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
final class ProjectHelper
final class ProjectHelper implements LocaleAwareInterface
{
public const PATTERN_NAME = '{name}';
public const PATTERN_COMMENT = '{comment}';
@@ -30,11 +31,22 @@ final class ProjectHelper
private ?string $pattern = null;
private bool $showStart = false;
private bool $showEnd = false;
private ?string $locale = null;
public function __construct(private SystemConfiguration $configuration, private LocaleService $localeService, private TranslatorInterface $translator)
{
}
public function getLocale(): string
{
return $this->locale ?? \Locale::getDefault();
}
public function setLocale(string $locale): void
{
$this->locale = $locale;
}
public function getChoicePattern(): string
{
if ($this->pattern === null) {
@@ -56,26 +68,27 @@ final class ProjectHelper
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $project->getName(), $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()->getName() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
$name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber() ?? '', $name);
if ($this->dateFormatter === null) {
$this->showStart = stripos($name, self::PATTERN_START) !== false;
$this->showEnd = stripos($name, self::PATTERN_END) !== false;
$locale = $this->getLocale();
$this->dateFormatter = new \IntlDateFormatter(
\Locale::getDefault(),
$locale,
\IntlDateFormatter::MEDIUM,
\IntlDateFormatter::MEDIUM,
date_default_timezone_get(),
\IntlDateFormatter::GREGORIAN,
$this->localeService->getDateFormat(\Locale::getDefault())
$this->localeService->getDateFormat($locale)
);
}
if ($this->showStart) {
$start = '';
if ($project->getStart() !== null) {
$start = $this->translator->trans('project_start') . ': ' . $this->dateFormatter->format($project->getStart()) . ' ';
$start = $this->translator->trans('project_start') . ': ' . $this->dateFormatter->format($project->getStart());
}
$name = str_replace(self::PATTERN_START, $start, $name);
}
@@ -83,14 +96,18 @@ final class ProjectHelper
if ($this->showEnd) {
$end = '';
if ($project->getEnd() !== null) {
$end = ' ' . $this->translator->trans('project_end') . ': ' . $this->dateFormatter->format($project->getEnd());
$end = $this->translator->trans('project_end') . ': ' . $this->dateFormatter->format($project->getEnd());
}
$name = str_replace(self::PATTERN_END, $end, $name);
}
$name = ltrim($name, self::SPACER);
$name = rtrim($name, self::SPACER);
$name = str_replace('- ?-?', '', $name);
while (str_starts_with($name, self::SPACER)) {
$name = substr($name, \strlen(self::SPACER));
}
while (str_ends_with($name, self::SPACER)) {
$name = substr($name, 0, -\strlen(self::SPACER));
}
if ($name === '' || $name === self::SPACER) {
$name = $project->getName();

View File

@@ -39,13 +39,11 @@ final class RateService implements RateServiceInterface
if (null !== $rate) {
if ($rate->isFixed()) {
$fixedRate ??= $rate->getRate();
$fixedInternalRate = $rate->getRate();
if (null !== $rate->getInternalRate()) {
$fixedInternalRate = $rate->getInternalRate();
}
} else {
$hourlyRate ??= $rate->getRate();
$internalRate = $rate->getRate();
if (null !== $rate->getInternalRate()) {
$internalRate = $rate->getInternalRate();
}

View File

@@ -7,18 +7,20 @@
{% endblock %}
{% block main %}
<div class="row row-cards">
<div class="row row-deck row-cards">
{% for report in reports %}
<div class="col-md-6 col-lg-3">
<div class="card">
<div class="card-body p-4 text-center">
<span class="avatar avatar-xl mb-3 avatar-rounded"><i class="{{ report.reportIcon|icon(false, report.reportIcon) }}" style="color: {{ null|colorize(report.reportIcon) }}"></i></span>
<div class="col-md-6 col-lg-4 col-xxl-3">
<a class="card card-link" href="{{ path(report.route) }}">
<div class="card-body d-flex align-items-center">
<div class="row">
<div class="col-auto">
<span class="avatar"><i class="icon {{ report.reportIcon|icon(false, report.reportIcon) }}" style="color: {{ null|colorize(report.reportIcon) }}"></i></span>
</div>
<div class="col p-2">{{ report.label|trans({}, 'reporting') }}</div>
</div>
</div>
<div class="d-flex">
<a href="{{ path(report.route) }}" class="card-btn">{{ report.label|trans({}, 'reporting') }}</a>
</div>
</div>
</a>
</div>
{% endfor %}

View File

@@ -14,6 +14,7 @@
{% set day = null %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% set dayInternalRate = {} %}
{% set dayHourlyRate = 0 %}
{% set lastEntry = null %}
@@ -22,10 +23,11 @@
{% set day = entry.begin|date_short %}
{% endif %}
{%- if showSummary and day is not same as(entry.begin|date_short) -%}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, sortedColumns, dataTable) }}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayInternalRate, dayRate, sortedColumns, dataTable) }}
{% set day = entry.begin|date_short %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% set dayInternalRate = {} %}
{% set dayHourlyRate = 0 %}
{%- endif -%}
{%- set customerCurrency = entry.project.customer.currency -%}
@@ -41,7 +43,11 @@
{% if dayRate[customerCurrency] is not defined %}
{% set dayRate = dayRate|merge({(customerCurrency): 0}) %}
{% endif %}
{% if dayInternalRate[customerCurrency] is not defined %}
{% set dayInternalRate = dayInternalRate|merge({(customerCurrency): 0}) %}
{% endif %}
{% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %}
{% set dayInternalRate = dayInternalRate|merge({(customerCurrency): dayInternalRate[customerCurrency] + entry.internalRate}) %}
{%- endif -%}
{% if dayHourlyRate is not null %}
{% if dayHourlyRate == 0 %}
@@ -54,7 +60,7 @@
{% set lastEntry = entry %}
{% endfor %}
{% if showSummary %}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, sortedColumns, dataTable) }}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayInternalRate, dayRate, sortedColumns, dataTable) }}
{% endif %}
{% endblock %}
@@ -107,11 +113,17 @@
{% elseif column == 'hourlyRate' %}
{{ entryHourlyRate }}
{% elseif column == 'rate' %}
{% if not entry.end or not is_granted('view_rate', entry) %}
{% if not entry.end or not view_rate %}
&dash;
{% else %}
{{ entry.rate|money(customerCurrency) }}
{% endif %}
{% elseif column == 'internalRate' %}
{% if not entry.end or not view_rate %}
&dash;
{% else %}
{{ entry.internalRate|money(customerCurrency) }}
{% endif %}
{% elseif column == 'customer' %}
{{ widgets.label_customer(entry.project.customer) }}
{% elseif column == 'project' %}
@@ -143,7 +155,7 @@
</td>
{% endblock %}
{% macro summary(day, duration, dayHourlyRate, dayRates, sortedColumns, dataTable) %}
{% macro summary(day, duration, dayHourlyRate, dayInternalRates, dayRates, sortedColumns, dataTable) %}
{% import "macros/datatables.html.twig" as tables %}
<tr class="summary info">
{% for column, data in sortedColumns %}
@@ -163,6 +175,13 @@
<br>
{% endif %}
{% endfor %}
{% elseif column == 'internalRate' %}
{% for currency, rate in dayInternalRates %}
{{ rate|money(currency) }}
{% if not loop.last %}
<br>
{% endif %}
{% endfor %}
{% else %}
{% endif %}

View File

@@ -25,7 +25,7 @@ class ReportingControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->request($client, '/reporting/');
$nodes = $client->getCrawler()->filter('section.content div.card');
$nodes = $client->getCrawler()->filter('section.content div.row-cards a.card-link');
$this->assertCount(11, $nodes);
}
@@ -33,7 +33,7 @@ class ReportingControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->request($client, '/reporting/');
$nodes = $client->getCrawler()->filter('section.content div.card');
$nodes = $client->getCrawler()->filter('section.content div.row-cards a.card-link');
$this->assertCount(3, $nodes);
}
}

View File

@@ -0,0 +1,62 @@
<?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\Form\Helper;
use App\Entity\Activity;
use App\Form\Helper\ActivityHelper;
use App\Tests\Mocks\SystemConfigurationFactory;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Form\Helper\ActivityHelper
*/
class ActivityHelperTest extends TestCase
{
private function createSut(string $format): ActivityHelper
{
$config = SystemConfigurationFactory::createStub(['activity.choice_pattern' => $format]);
$helper = new ActivityHelper($config);
return $helper;
}
public function testInvalidPattern(): void
{
$helper = $this->createSut('sdfsdf');
self::assertEquals(ActivityHelper::PATTERN_NAME, $helper->getChoicePattern());
}
public function testGetChoicePattern(): void
{
$helper = $this->createSut(
ActivityHelper::PATTERN_NAME . ActivityHelper::PATTERN_SPACER .
ActivityHelper::PATTERN_COMMENT
);
self::assertEquals(
ActivityHelper::PATTERN_NAME . ActivityHelper::SPACER .
ActivityHelper::PATTERN_COMMENT,
$helper->getChoicePattern()
);
}
public function testGetChoiceLabel(): void
{
$helper = $this->createSut(ActivityHelper::PATTERN_NAME . ActivityHelper::PATTERN_SPACER . ActivityHelper::PATTERN_COMMENT);
$activity = new Activity();
$activity->setName(' - --- - -FOO BAR- --- - - - ');
self::assertEquals('--- - -FOO BAR- ---', $helper->getChoiceLabel($activity));
$activity->setName('FOO BAR');
$activity->setComment('Lorem Ipsum');
self::assertEquals('FOO BAR - Lorem Ipsum', $helper->getChoiceLabel($activity));
}
}

View File

@@ -0,0 +1,74 @@
<?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\Form\Helper;
use App\Entity\Customer;
use App\Form\Helper\CustomerHelper;
use App\Tests\Mocks\SystemConfigurationFactory;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Form\Helper\CustomerHelper
*/
class CustomerHelperTest extends TestCase
{
private function createSut(string $format): CustomerHelper
{
$config = SystemConfigurationFactory::createStub(['customer.choice_pattern' => $format]);
$helper = new CustomerHelper($config);
return $helper;
}
public function testInvalidPattern(): void
{
$helper = $this->createSut('sdfsdf');
self::assertEquals(CustomerHelper::PATTERN_NAME, $helper->getChoicePattern());
}
public function testGetChoicePattern(): void
{
$helper = $this->createSut(
CustomerHelper::PATTERN_NAME . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_COMMENT . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_COMPANY . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_NUMBER
);
self::assertEquals(
CustomerHelper::PATTERN_NAME . CustomerHelper::SPACER .
CustomerHelper::PATTERN_COMMENT . CustomerHelper::SPACER .
CustomerHelper::PATTERN_COMPANY . CustomerHelper::SPACER .
CustomerHelper::PATTERN_NUMBER,
$helper->getChoicePattern()
);
}
public function testGetChoiceLabel(): void
{
$helper = $this->createSut(
CustomerHelper::PATTERN_NAME . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_COMMENT . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_COMPANY . CustomerHelper::PATTERN_SPACER .
CustomerHelper::PATTERN_NUMBER
);
$customer = new Customer(' - --- - -FOO BAR- --- - - - ');
self::assertEquals('--- - -FOO BAR- ---', $helper->getChoiceLabel($customer));
$customer = new Customer('FOO BAR');
$customer->setComment('Lorem Ipsum');
self::assertEquals('FOO BAR - Lorem Ipsum', $helper->getChoiceLabel($customer));
$customer->setCompany('Acme University');
self::assertEquals('FOO BAR - Lorem Ipsum - Acme University', $helper->getChoiceLabel($customer));
$customer->setNumber('2023-0815');
self::assertEquals('FOO BAR - Lorem Ipsum - Acme University - 2023-0815', $helper->getChoiceLabel($customer));
}
}

View File

@@ -0,0 +1,109 @@
<?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\Form\Helper;
use App\Configuration\LocaleService;
use App\Entity\Customer;
use App\Entity\Project;
use App\Form\Helper\ProjectHelper;
use App\Tests\Mocks\SystemConfigurationFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @covers \App\Form\Helper\ProjectHelper
*/
class ProjectHelperTest extends TestCase
{
private function createSut(string $format): ProjectHelper
{
$config = SystemConfigurationFactory::createStub(['project.choice_pattern' => $format]);
$localeService = new LocaleService(['en_US' => ['date' => 'dd.MM.y']]);
$translator = $this->createMock(TranslatorInterface::class);
$translator->method('trans')->willReturn('dating');
$helper = new ProjectHelper($config, $localeService, $translator);
$helper->setLocale('en_US');
return $helper;
}
public function testInvalidPattern(): void
{
$helper = $this->createSut('sdfsdf');
self::assertEquals(ProjectHelper::PATTERN_NAME, $helper->getChoicePattern());
}
public function testGetChoicePattern(): void
{
$helper = $this->createSut(
ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_COMMENT . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_CUSTOMER . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_ORDERNUMBER . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_START . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_END . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_DATERANGE
);
self::assertEquals(
ProjectHelper::PATTERN_NAME . ProjectHelper::SPACER .
ProjectHelper::PATTERN_COMMENT . ProjectHelper::SPACER .
ProjectHelper::PATTERN_CUSTOMER . ProjectHelper::SPACER .
ProjectHelper::PATTERN_ORDERNUMBER . ProjectHelper::SPACER .
ProjectHelper::PATTERN_START . ProjectHelper::SPACER .
ProjectHelper::PATTERN_END . ProjectHelper::SPACER .
ProjectHelper::PATTERN_START . '-' . ProjectHelper::PATTERN_END,
$helper->getChoicePattern()
);
}
public function testGetChoiceLabel(): void
{
$helper = $this->createSut(
ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_COMMENT . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_CUSTOMER . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_ORDERNUMBER
);
$project = new Project();
$project->setName(' - --- - -FOO BAR- --- - - - ');
$customer = new Customer(' - --- - - Acme company- --- - - - ');
$project->setCustomer($customer);
self::assertEquals('--- - -FOO BAR- --- - - - - - - --- - - Acme company- ---', $helper->getChoiceLabel($project));
$project = new Project();
$project->setName('FOO BAR');
$customer = new Customer('Acme company');
$project->setCustomer($customer);
$project->setComment('Lorem Ipsum');
$project->setOrderNumber('F76/123');
self::assertEquals('FOO BAR - Lorem Ipsum - Acme company - F76/123', $helper->getChoiceLabel($project));
}
public function testGetChoiceLabelWithDates(): void
{
$helper = $this->createSut(
ProjectHelper::PATTERN_NAME . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_START . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_END . ProjectHelper::PATTERN_SPACER .
ProjectHelper::PATTERN_DATERANGE
);
$project = new Project();
$project->setName('FOO BAR');
self::assertEquals('FOO BAR - - - -', $helper->getChoiceLabel($project));
$project->setStart(new \DateTime('2018-12-27 18:45:12'));
self::assertEquals('FOO BAR - dating: 27.12.2018 - - dating: 27.12.2018-', $helper->getChoiceLabel($project));
$project->setEnd(new \DateTime('2019-02-14 01:23:45'));
self::assertEquals('FOO BAR - dating: 27.12.2018 - dating: 14.02.2019 - dating: 27.12.2018-dating: 14.02.2019', $helper->getChoiceLabel($project));
}
}

View File

@@ -76,25 +76,25 @@ class RateCalculatorTest extends TestCase
yield 'a6' => [0.5, 6.72, 1800, 1, 13.44, null, null, null, null, false, null, null, false, null, null, false];
yield 'a8' => [0.0, 1, 0, 0, 0, 0, 0, 0, 1, true, 0, null, true, 0, null, true];
// rate: 1.5 => timesheet hourly rate , internal: 2.5 => activity hourly rate (30 min)
yield 'b1' => [1.5, 2.5, 1800, 1, 1, 3, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b2' => [2.5, 2.5, 1800, 1, 1, null, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b1' => [1.5, 0.5, 1800, 1, 1, 3, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b2' => [2.5, 4.5, 1800, 1, 9, null, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b3' => [3.5, 6.5, 1800, 1, 1, null, null, null, null, false, 7, 13, false, 9, 9, false];
yield 'b4' => [4.5, 6.5, 1800, 1, 15, null, null, null, null, false, null, null, false, 9, 13, false];
// rate: 2.0 => timesheet fixed rate , internal: 3.0 => activity fixed rate
yield 'b5' => [2.0, 3.0, 1800, 1, 1, null, 2, 3, null, true, 4, null, true, 5, null, true];
yield 'b6' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, 4, null, true, 5, null, true];
yield 'b7' => [4.0, 4.0, 1800, 1, 1, null, null, null, null, false, 4, null, true, 5, null, true];
yield 'b8' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, null, null, false, 5, null, true];
yield 'b5' => [2.0, 1.0, 1800, 1, 1, null, 2, 3, null, true, 4, null, true, 5, null, true];
yield 'b6' => [3.0, 3.0, 1800, 1, 3, null, null, 3, null, true, 4, null, true, 5, null, true];
yield 'b7' => [4.0, 7.0, 1800, 1, 7, null, null, null, null, false, 4, null, true, 5, null, true];
yield 'b8' => [3.0, 4.7, 1800, 1, 4.7, null, null, 3, null, true, null, null, false, 5, null, true];
// rate: 2.0 => timesheet fixed rate , internal: 5.0 => customer hourly rate
yield 'b9' => [2.0, 5.0, 1800, 1, 1, null, 2, null, null, false, null, null, false, 5, null, true];
yield 'b9' => [2.0, 71.0, 1800, 1, 71, null, 2, null, null, false, null, null, false, 5, null, true];
// rate: 5.0 => timesheet hourly rate , internal: 7.5 => user internal rate (30 min)
yield 'c0' => [5.0, 7.5, 1800, 100, 15, 10, null, null, null, false, null, null, false, null, null, false];
// internal: 10 because no rule applies and as fallback the users internal rate is used
yield 'd0' => [10, 100, 1800, 100, 100, null, 10, null, null, false, null, null, false, null, null, false];
yield 'e0' => [10, 10, 1800, 100, 100, null, null, 20, null, false, null, null, false, null, null, false];
yield 'e0' => [10, 50, 1800, 100, 100, null, null, 20, null, false, null, null, false, null, null, false];
yield 'f0' => [20, 78, 1800, 100, 100, null, null, 20, 78, true, null, null, false, null, null, false];
yield 'g0' => [15, 11.5, 1800, 100, 100, null, null, null, null, false, 30, 23, false, null, null, false];
yield 'h0' => [30, 30, 1800, 100, 100, null, null, null, null, false, 30, null, true, null, null, false];
yield 'h0' => [30, 100, 1800, 100, 100, null, null, null, null, false, 30, null, true, null, null, false];
yield 'i0' => [20, 13.5, 1800, 100, 100, null, null, null, null, false, null, null, false, 40, 27, false];
yield 'j0' => [40, 84, 1800, 100, 45, null, null, null, null, false, null, null, false, 40, 84, true];
// make sure the last fallback for the internal rate is the users hourly rate

View File

@@ -80,25 +80,25 @@ class RateServiceTest extends TestCase
yield 'a6' => [0.5, 6.72, 1800, 1, 13.44, null, null, null, null, false, null, null, false, null, null, false];
yield 'a8' => [0.0, 1, 0, 0, 0, 0, 0, 0, 1, true, 0, null, true, 0, null, true];
// rate: 1.5 => timesheet hourly rate , internal: 2.5 => activity hourly rate (30 min)
yield 'b1' => [1.5, 2.5, 1800, 1, 1, 3, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b2' => [2.5, 2.5, 1800, 1, 1, null, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b1' => [1.5, 0.5, 1800, 1, 1, 3, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b2' => [2.5, 4.5, 1800, 1, 9, null, null, 5, null, false, 7, null, false, 9, null, false];
yield 'b3' => [3.5, 6.5, 1800, 1, 1, null, null, null, null, false, 7, 13, false, 9, 9, false];
yield 'b4' => [4.5, 6.5, 1800, 1, 15, null, null, null, null, false, null, null, false, 9, 13, false];
// rate: 2.0 => timesheet fixed rate , internal: 3.0 => activity fixed rate
yield 'b5' => [2.0, 3.0, 1800, 1, 1, null, 2, 3, null, true, 4, null, true, 5, null, true];
yield 'b6' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, 4, null, true, 5, null, true];
yield 'b7' => [4.0, 4.0, 1800, 1, 1, null, null, null, null, false, 4, null, true, 5, null, true];
yield 'b8' => [3.0, 3.0, 1800, 1, 1, null, null, 3, null, true, null, null, false, 5, null, true];
yield 'b5' => [2.0, 1.0, 1800, 1, 1, null, 2, 3, null, true, 4, null, true, 5, null, true];
yield 'b6' => [3.0, 3.0, 1800, 1, 3, null, null, 3, null, true, 4, null, true, 5, null, true];
yield 'b7' => [4.0, 7.0, 1800, 1, 7, null, null, null, null, false, 4, null, true, 5, null, true];
yield 'b8' => [3.0, 4.7, 1800, 1, 4.7, null, null, 3, null, true, null, null, false, 5, null, true];
// rate: 2.0 => timesheet fixed rate , internal: 5.0 => customer hourly rate
yield 'b9' => [2.0, 5.0, 1800, 1, 1, null, 2, null, null, false, null, null, false, 5, null, true];
yield 'b9' => [2.0, 71.0, 1800, 1, 71, null, 2, null, null, false, null, null, false, 5, null, true];
// rate: 5.0 => timesheet hourly rate , internal: 7.5 => user internal rate (30 min)
yield 'c0' => [5.0, 7.5, 1800, 100, 15, 10, null, null, null, false, null, null, false, null, null, false];
// internal: 10 because no rule applies and as fallback the users internal rate is used
yield 'd0' => [10, 100, 1800, 100, 100, null, 10, null, null, false, null, null, false, null, null, false];
yield 'e0' => [10, 10, 1800, 100, 100, null, null, 20, null, false, null, null, false, null, null, false];
yield 'e0' => [10, 50, 1800, 100, 100, null, null, 20, null, false, null, null, false, null, null, false];
yield 'f0' => [20, 78, 1800, 100, 100, null, null, 20, 78, true, null, null, false, null, null, false];
yield 'g0' => [15, 11.5, 1800, 100, 100, null, null, null, null, false, 30, 23, false, null, null, false];
yield 'h0' => [30, 30, 1800, 100, 100, null, null, null, null, false, 30, null, true, null, null, false];
yield 'h0' => [30, 100, 1800, 100, 100, null, null, null, null, false, 30, null, true, null, null, false];
yield 'i0' => [20, 13.5, 1800, 100, 100, null, null, null, null, false, null, null, false, 40, 27, false];
yield 'j0' => [40, 84, 1800, 100, 45, null, null, null, null, false, null, null, false, 40, 84, true];
// make sure the last fallback for the internal rate is the users hourly rate

View File

@@ -6,6 +6,10 @@
<source>stop</source>
<target>Beenden</target>
</trans-unit>
<trans-unit id="QCf1FUG" resname="reload">
<source>reload</source>
<target>Neu laden</target>
</trans-unit>
<trans-unit id="PEgjRvN" resname="back">
<source>back</source>
<target>Zurück</target>

View File

@@ -6,6 +6,10 @@
<source>stop</source>
<target>Stop</target>
</trans-unit>
<trans-unit id="QCf1FUG" resname="reload">
<source>reload</source>
<target>Reload</target>
</trans-unit>
<trans-unit id="PEgjRvN" resname="back">
<source>back</source>
<target>Back</target>

View File

@@ -1081,6 +1081,14 @@
<source>invoice.service_date</source>
<target>Leistungsdatum</target>
</trans-unit>
<trans-unit id="NOEx3FF" resname="invoice_date.help">
<source>invoice_date.help</source>
<target>Hier können Sie das Rechnungsdatum ändern</target>
</trans-unit>
<trans-unit id="F7xeAn4" resname="invoice_date">
<source>invoice_date</source>
<target>Rechnungsdatum</target>
</trans-unit>
<trans-unit id="zzjZXJx" resname="amount">
<source>amount</source>
<target>Anzahl</target>

View File

@@ -1081,6 +1081,14 @@
<source>invoice.service_date</source>
<target>Service date</target>
</trans-unit>
<trans-unit id="NOEx3FF" resname="invoice_date.help">
<source>invoice_date.help</source>
<target>You can change the invoice date here</target>
</trans-unit>
<trans-unit id="F7xeAn4" resname="invoice_date">
<source>invoice_date</source>
<target>Invoice date</target>
</trans-unit>
<trans-unit id="zzjZXJx" resname="amount">
<source>amount</source>
<target>Quantity</target>