1.18 (#3158)
* bump version * fix translation ids * added css classes to modify form with custom css * improve export pdf file names * respect financial year in new report * added new InvoiceCalculator: price * upgrade packages and node-sass to v7 * title pattern for customer, project and activity via API * support negative money without currency * fix sub-locale in print export template * fix overbooking validation for monthly budget * fix copying entities with different set of custom-fields compared to the current configuration
This commit is contained in:
@@ -42,7 +42,7 @@ export default class KimaiPlugin {
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns null, if xou call it BEFORE init().
|
||||
* This function returns null, if you call it BEFORE init().
|
||||
*
|
||||
* @returns {KimaiContainer}
|
||||
*/
|
||||
|
||||
@@ -37,7 +37,11 @@ export default class KimaiActiveRecordsDuration extends KimaiPlugin {
|
||||
|
||||
if (activeRecords.length === 0) {
|
||||
if (this.updateBrowserTitle) {
|
||||
document.title = document.querySelector('body').dataset['title'];
|
||||
if (document.body.dataset['title'] === undefined) {
|
||||
this.updateBrowserTitle = false;
|
||||
} else {
|
||||
document.title = document.body.dataset['title'];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ export default class KimaiDateUtils extends KimaiPlugin {
|
||||
return 'date';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dateTime
|
||||
* @returns {string}
|
||||
*/
|
||||
getFormattedDate(dateTime) {
|
||||
return moment(dateTime).format(this.getConfiguration('formatDate'));
|
||||
}
|
||||
|
||||
getWeekDaysShort() {
|
||||
return moment.localeData().weekdaysShort();
|
||||
}
|
||||
|
||||
@@ -161,18 +161,25 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
|
||||
let emptyOpts = [];
|
||||
let options = [];
|
||||
let titlePattern = null;
|
||||
if (select[0].dataset['optionPattern'] !== undefined) {
|
||||
titlePattern = select[0].dataset['optionPattern'];
|
||||
}
|
||||
if (titlePattern === null || titlePattern === '') {
|
||||
titlePattern = '{name}';
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === '__empty__') {
|
||||
for (const entity of value) {
|
||||
emptyOpts.push(this._createOption(entity.name, entity.id));
|
||||
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let optGroup = this._createOptgroup(key);
|
||||
for (const entity of value) {
|
||||
optGroup.appendChild(this._createOption(entity.name, entity.id));
|
||||
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
|
||||
}
|
||||
options.push(optGroup);
|
||||
}
|
||||
@@ -192,6 +199,46 @@ export default class KimaiFormSelect extends KimaiPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {array} entity
|
||||
* @private
|
||||
*/
|
||||
_getTitleFromPattern(pattern, entity) {
|
||||
const DATE_UTILS = this.getPlugin('date');
|
||||
const regexp = new RegExp('{[^}]*?}','g');
|
||||
let title = pattern;
|
||||
let match = null;
|
||||
|
||||
while ((match = regexp.exec(pattern)) !== null) {
|
||||
const field = match[0].substr(1, match[0].length - 2);
|
||||
let value = entity[field] === undefined ? null : entity[field];
|
||||
if ((field === 'start' || field === 'end')) {
|
||||
if (value === null) {
|
||||
value = '?';
|
||||
} else {
|
||||
value = DATE_UTILS.getFormattedDate(value);
|
||||
}
|
||||
}
|
||||
|
||||
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
|
||||
}
|
||||
title = title.replace(/- \?-\?/, '');
|
||||
|
||||
const chars = '- ';
|
||||
let start = 0, end = title.length;
|
||||
|
||||
while(start < end && chars.indexOf(title[start]) >= 0) {
|
||||
++start;
|
||||
}
|
||||
|
||||
while(end > start && chars.indexOf(title[end - 1]) >= 0) {
|
||||
--end;
|
||||
}
|
||||
|
||||
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} label
|
||||
* @param {string} value
|
||||
|
||||
@@ -142,7 +142,9 @@ export default class KimaiSelectDataAPI extends KimaiPlugin {
|
||||
ordered[key] = options[key];
|
||||
});
|
||||
|
||||
this.getContainer().getPlugin('form-select').updateOptions(selectName, ordered);
|
||||
/** @var {KimaiFormSelect} select */
|
||||
const select = this.getContainer().getPlugin('form-select');
|
||||
select.updateOptions(selectName, ordered);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"jquery-ui": "^1.12",
|
||||
"js-cookie": "^2.2",
|
||||
"moment": "^2.29",
|
||||
"node-sass": "^4.14",
|
||||
"sass-loader": "^10.0",
|
||||
"node-sass": "^7.0",
|
||||
"sass-loader": "^11.0",
|
||||
"select2": "^4.0",
|
||||
"select2-bootstrap-theme": "^0.1.0-beta.10",
|
||||
"sweetalert2": "^10.16",
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
public/build/app.94572a11.js
Normal file
2
public/build/app.94572a11.js
Normal file
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
||||
"app": {
|
||||
"js": [
|
||||
"build/runtime.b8e7bb04.js",
|
||||
"build/app.8609d161.js"
|
||||
"build/app.94572a11.js"
|
||||
],
|
||||
"css": [
|
||||
"build/app.3bc2b4d9.css"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"build/app.css": "build/app.3bc2b4d9.css",
|
||||
"build/app.js": "build/app.8609d161.js",
|
||||
"build/app.js": "build/app.94572a11.js",
|
||||
"build/invoice.css": "build/invoice.ff32661a.css",
|
||||
"build/invoice.js": "build/invoice.19f36eca.js",
|
||||
"build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
|
||||
|
||||
@@ -17,15 +17,15 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '1.17.1';
|
||||
public const VERSION = '1.18';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 11701;
|
||||
public const VERSION_ID = 11800;
|
||||
/**
|
||||
* The current release status, either "stable" or "dev"
|
||||
*/
|
||||
public const STATUS = 'prod';
|
||||
public const STATUS = 'stable';
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -76,7 +76,7 @@ final class ReportUsersYearController extends AbstractController
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($currentUser);
|
||||
$allUsers = $userRepository->getUsersForQuery($query);
|
||||
$defaultDate = $dateTimeFactory->createDateTime('01 january this year 00:00:00');
|
||||
$defaultDate = $dateTimeFactory->createStartOfYear();
|
||||
|
||||
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
|
||||
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Controller\Reporting;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\User;
|
||||
use App\Model\DateStatisticInterface;
|
||||
use App\Model\MonthlyStatistic;
|
||||
@@ -35,12 +36,12 @@ final class UserYearController extends AbstractUserReportController
|
||||
* @return Response
|
||||
* @throws Exception
|
||||
*/
|
||||
public function yearByUser(Request $request): Response
|
||||
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request));
|
||||
return $this->render('reporting/report_by_user_year.html.twig', $this->getData($request, $systemConfiguration));
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
@@ -48,7 +49,14 @@ final class UserYearController extends AbstractUserReportController
|
||||
|
||||
$values = new YearByUser();
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->createStartOfYear());
|
||||
|
||||
$defaultDate = $dateTimeFactory->createStartOfYear();
|
||||
|
||||
if (null !== ($financialYear = $systemConfiguration->getFinancialYearStart())) {
|
||||
$defaultDate = $this->getDateTimeFactory()->createStartOfFinancialYear($financialYear);
|
||||
}
|
||||
|
||||
$values->setDate(clone $defaultDate);
|
||||
|
||||
$form = $this->createForm(YearByUserForm::class, $values, [
|
||||
'include_user' => $canChangeUser,
|
||||
@@ -67,11 +75,14 @@ final class UserYearController extends AbstractUserReportController
|
||||
}
|
||||
|
||||
if ($values->getDate() === null) {
|
||||
$values->setDate($dateTimeFactory->createStartOfYear());
|
||||
$values->setDate(clone $defaultDate);
|
||||
}
|
||||
|
||||
$start = $dateTimeFactory->createStartOfYear($values->getDate());
|
||||
$end = $dateTimeFactory->createEndOfYear($values->getDate());
|
||||
$start = $values->getDate();
|
||||
// there is a potential edge case bug for financial years:
|
||||
// the last month will be skipped, if the financial year started on a different day than the first
|
||||
$end = $dateTimeFactory->createEndOfFinancialYear($start);
|
||||
|
||||
$selectedUser = $values->getUser();
|
||||
|
||||
$previous = clone $start;
|
||||
|
||||
@@ -15,6 +15,8 @@ use App\Repository\ActivityRepository;
|
||||
use App\Repository\Query\ActivityFormTypeQuery;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -36,7 +38,7 @@ class ActivityType extends AbstractType
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Activity $activity): string
|
||||
private function getPattern(): string
|
||||
{
|
||||
if ($this->pattern === null) {
|
||||
$this->pattern = $this->configuration->find('activity.choice_pattern');
|
||||
@@ -44,12 +46,18 @@ class ActivityType extends AbstractType
|
||||
if ($this->pattern === null || stripos($this->pattern, '{') === false || stripos($this->pattern, '}') === false) {
|
||||
$this->pattern = self::PATTERN_NAME;
|
||||
}
|
||||
|
||||
$this->pattern = str_replace(self::PATTERN_SPACER, self::SPACER, $this->pattern);
|
||||
}
|
||||
|
||||
$name = $this->pattern;
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Activity $activity): string
|
||||
{
|
||||
$name = $this->getPattern();
|
||||
$name = str_replace(self::PATTERN_NAME, $activity->getName(), $name);
|
||||
$name = str_replace(self::PATTERN_COMMENT, $activity->getComment(), $name);
|
||||
$name = str_replace(self::PATTERN_SPACER, self::SPACER, $name);
|
||||
|
||||
$name = ltrim($name, self::SPACER);
|
||||
$name = rtrim($name, self::SPACER);
|
||||
@@ -130,6 +138,13 @@ class ActivityType extends AbstractType
|
||||
});
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options)
|
||||
{
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-option-pattern' => $this->getPattern(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,8 @@ use App\Repository\Query\CustomerFormTypeQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -39,7 +41,7 @@ class CustomerType extends AbstractType
|
||||
$this->configuration = $configuration;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Customer $customer): string
|
||||
private function getPattern(): string
|
||||
{
|
||||
if ($this->pattern === null) {
|
||||
$this->pattern = $this->configuration->find('customer.choice_pattern');
|
||||
@@ -47,14 +49,20 @@ class CustomerType extends AbstractType
|
||||
if ($this->pattern === null || stripos($this->pattern, '{') === false || stripos($this->pattern, '}') === false) {
|
||||
$this->pattern = self::PATTERN_NAME;
|
||||
}
|
||||
|
||||
$this->pattern = str_replace(self::PATTERN_SPACER, self::SPACER, $this->pattern);
|
||||
}
|
||||
|
||||
$name = $this->pattern;
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Customer $customer): string
|
||||
{
|
||||
$name = $this->getPattern();
|
||||
$name = str_replace(self::PATTERN_NAME, $customer->getName(), $name);
|
||||
$name = str_replace(self::PATTERN_COMMENT, $customer->getComment(), $name);
|
||||
$name = str_replace(self::PATTERN_NUMBER, $customer->getNumber(), $name);
|
||||
$name = str_replace(self::PATTERN_COMPANY, $customer->getCompany() ?? $customer->getName(), $name);
|
||||
$name = str_replace(self::PATTERN_SPACER, self::SPACER, $name);
|
||||
|
||||
$name = ltrim($name, self::SPACER);
|
||||
$name = rtrim($name, self::SPACER);
|
||||
@@ -147,6 +155,13 @@ class CustomerType extends AbstractType
|
||||
});
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options)
|
||||
{
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-option-pattern' => $this->getPattern(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
|
||||
namespace App\Form\Type;
|
||||
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
@@ -18,6 +23,23 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
*/
|
||||
class MetaFieldsCollectionType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder->addEventListener(
|
||||
FormEvents::PRE_SET_DATA,
|
||||
function (FormEvent $event) {
|
||||
/** @var ArrayCollection<MetaTableTypeInterface> $collection */
|
||||
$collection = $event->getData();
|
||||
foreach ($collection as $collectionItem) {
|
||||
$collection->removeElement($collectionItem);
|
||||
$collection->set($collectionItem->getName(), $collectionItem);
|
||||
}
|
||||
},
|
||||
// must be a higher priority then the listener in EntityMetaDefinitionType
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
|
||||
@@ -17,6 +17,8 @@ use App\Repository\Query\ProjectFormTypeQuery;
|
||||
use App\Utils\LocaleSettings;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\OptionsResolver\Options;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -45,52 +47,48 @@ class ProjectType extends AbstractType
|
||||
$this->localeSettings = $localeSettings;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Project $project): string
|
||||
private function getPattern(): string
|
||||
{
|
||||
if ($this->dateFormat === null) {
|
||||
$this->dateFormat = $this->localeSettings->getDateFormat();
|
||||
}
|
||||
|
||||
if ($this->pattern === null) {
|
||||
$this->pattern = $this->configuration->find('project.choice_pattern');
|
||||
|
||||
if ($this->pattern === null || stripos($this->pattern, '{') === false || stripos($this->pattern, '}') === false) {
|
||||
$this->pattern = self::PATTERN_NAME;
|
||||
}
|
||||
|
||||
$this->pattern = str_replace(self::PATTERN_DATERANGE, self::PATTERN_START . '-' . self::PATTERN_END, $this->pattern);
|
||||
$this->pattern = str_replace(self::PATTERN_SPACER, self::SPACER, $this->pattern);
|
||||
}
|
||||
|
||||
$dateRange = '';
|
||||
if ($project->getStart() !== null) {
|
||||
$dateRange = self::PATTERN_START;
|
||||
}
|
||||
if ($project->getEnd() !== null) {
|
||||
if ($dateRange !== '') {
|
||||
$dateRange .= '-';
|
||||
}
|
||||
$dateRange .= self::PATTERN_END;
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
public function getChoiceLabel(Project $project): string
|
||||
{
|
||||
if ($this->dateFormat === null) {
|
||||
$this->dateFormat = $this->localeSettings->getDateFormat();
|
||||
}
|
||||
|
||||
$start = '';
|
||||
$start = '?';
|
||||
if ($project->getStart() !== null) {
|
||||
$start = $project->getStart()->format($this->dateFormat);
|
||||
}
|
||||
|
||||
$end = '';
|
||||
$end = '?';
|
||||
if ($project->getEnd() !== null) {
|
||||
$end = $project->getEnd()->format($this->dateFormat);
|
||||
}
|
||||
|
||||
$name = $this->pattern;
|
||||
$name = $this->getPattern();
|
||||
$name = str_replace(self::PATTERN_NAME, $project->getName(), $name);
|
||||
$name = str_replace(self::PATTERN_COMMENT, $project->getComment(), $name);
|
||||
$name = str_replace(self::PATTERN_ORDERNUMBER, $project->getOrderNumber(), $name);
|
||||
$name = str_replace(self::PATTERN_DATERANGE, $dateRange, $name);
|
||||
$name = str_replace(self::PATTERN_START, $start, $name);
|
||||
$name = str_replace(self::PATTERN_END, $end, $name);
|
||||
$name = str_replace(self::PATTERN_SPACER, self::SPACER, $name);
|
||||
|
||||
$name = ltrim($name, self::SPACER);
|
||||
$name = rtrim($name, self::SPACER);
|
||||
$name = str_replace('- ?-?', '', $name);
|
||||
|
||||
if ($name === '' || $name === self::SPACER) {
|
||||
$name = $project->getName();
|
||||
@@ -196,6 +194,13 @@ class ProjectType extends AbstractType
|
||||
});
|
||||
}
|
||||
|
||||
public function buildView(FormView $view, FormInterface $form, array $options)
|
||||
{
|
||||
$view->vars['attr'] = array_merge($view->vars['attr'], [
|
||||
'data-option-pattern' => $this->getPattern(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
36
src/Invoice/Calculator/PriceInvoiceCalculator.php
Normal file
36
src/Invoice/Calculator/PriceInvoiceCalculator.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?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\Invoice\Calculator;
|
||||
|
||||
use App\Invoice\CalculatorInterface;
|
||||
use App\Invoice\InvoiceItemInterface;
|
||||
|
||||
/**
|
||||
* A calculator that sums up the invoice item records by price.
|
||||
*/
|
||||
class PriceInvoiceCalculator extends AbstractSumInvoiceCalculator implements CalculatorInterface
|
||||
{
|
||||
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
|
||||
{
|
||||
if (null !== $invoiceItem->getFixedRate()) {
|
||||
return 'fixed_' . $invoiceItem->getFixedRate();
|
||||
}
|
||||
|
||||
return 'hourly_' . $invoiceItem->getHourlyRate();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return 'price';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Utils\Color;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* Multiple Twig extensions: filters and functions
|
||||
@@ -49,6 +50,15 @@ class Extensions extends AbstractExtension
|
||||
];
|
||||
}
|
||||
|
||||
public function getTests()
|
||||
{
|
||||
return [
|
||||
new TwigTest('number', function ($value) {
|
||||
return !\is_string($value) && is_numeric($value);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function formatReportDate(\DateTime $dateTime): string
|
||||
{
|
||||
return $dateTime->format('Y-m-d');
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Configuration\LanguageFormattings;
|
||||
use App\Constants;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Utils\LocaleFormats;
|
||||
use App\Utils\LocaleFormatter;
|
||||
use App\Utils\MomentFormatConverter;
|
||||
use DateTime;
|
||||
use Symfony\Component\Security\Core\Security;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
@@ -100,6 +102,7 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
public function getFunctions()
|
||||
{
|
||||
return [
|
||||
new TwigFunction('javascript_configurations', [$this, 'getJavascriptConfiguration']),
|
||||
new TwigFunction('get_format_duration', [$this, 'getDurationFormat']),
|
||||
new TwigFunction('create_date', [$this, 'createDate']),
|
||||
new TwigFunction('locales', [$this, 'getLocales']),
|
||||
@@ -265,8 +268,24 @@ final class LocaleFormatExtensions extends AbstractExtension
|
||||
return $user->is24Hour();
|
||||
}
|
||||
|
||||
public function getJavascriptConfiguration(User $user): array
|
||||
{
|
||||
$converter = new MomentFormatConverter();
|
||||
$format = $this->getLocaleFormats()->getDateTypeFormat();
|
||||
|
||||
return [
|
||||
'formatDuration' => $this->getLocaleFormats()->getDurationFormat(),
|
||||
'formatDate' => $converter->convert($format),
|
||||
'defaultColor' => Constants::DEFAULT_COLOR,
|
||||
'twentyFourHours' => $user->is24Hour(),
|
||||
'updateBrowserTitle' => (bool) $user->getPreferenceValue('theme.update_browser_title'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getDurationFormat(): string
|
||||
{
|
||||
@trigger_error('Twig function "get_format_duration()" is deprecated, use "javascript_configurations()" instead.', E_USER_DEPRECATED);
|
||||
|
||||
return $this->getLocaleFormats()->getDurationFormat();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,8 @@ final class LocaleHelper
|
||||
$this->moneyFormatterNoCurrency = new NumberFormatter($this->locale, NumberFormatter::CURRENCY);
|
||||
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_PREFIX, '');
|
||||
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::POSITIVE_SUFFIX, '');
|
||||
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_PREFIX, '-');
|
||||
$this->moneyFormatterNoCurrency->setTextAttribute(NumberFormatter::NEGATIVE_SUFFIX, '');
|
||||
}
|
||||
|
||||
return $this->moneyFormatterNoCurrency;
|
||||
|
||||
@@ -129,19 +129,23 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
|
||||
}
|
||||
|
||||
$now = new DateTime('now', $timesheet->getBegin()->getTimezone());
|
||||
$recordDate = $timesheet->getBegin();
|
||||
|
||||
if (null !== ($activity = $timesheet->getActivity()) && $activity->hasBudgets()) {
|
||||
$stat = $this->activityStatisticService->getBudgetStatisticModel($activity, $now);
|
||||
$dateTime = $activity->isMonthlyBudget() ? $recordDate : $now;
|
||||
$stat = $this->activityStatisticService->getBudgetStatisticModel($activity, $dateTime);
|
||||
$this->checkBudgets($constraint, $stat, $timesheet, $activityDuration, $activityRate, 'activity');
|
||||
}
|
||||
|
||||
if (null !== ($project = $timesheet->getProject())) {
|
||||
if ($project->hasBudgets()) {
|
||||
$stat = $this->projectStatisticService->getBudgetStatisticModel($project, $now);
|
||||
$dateTime = $project->isMonthlyBudget() ? $recordDate : $now;
|
||||
$stat = $this->projectStatisticService->getBudgetStatisticModel($project, $dateTime);
|
||||
$this->checkBudgets($constraint, $stat, $timesheet, $projectDuration, $projectRate, 'project');
|
||||
}
|
||||
if (null !== ($customer = $project->getCustomer()) && $customer->hasBudgets()) {
|
||||
$stat = $this->customerStatisticService->getBudgetStatisticModel($customer, $now);
|
||||
$dateTime = $customer->isMonthlyBudget() ? $recordDate : $now;
|
||||
$stat = $this->customerStatisticService->getBudgetStatisticModel($customer, $dateTime);
|
||||
$this->checkBudgets($constraint, $stat, $timesheet, $customerDuration, $customerRate, 'customer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,28 +288,8 @@
|
||||
|
||||
{% block javascripts %}
|
||||
{# no call to parent(), as we use a custom built for the frontend assets and don't want the default <script> #}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
var loader = new KimaiWebLoader(
|
||||
{
|
||||
formatDuration: '{{ get_format_duration() }}',
|
||||
login: '{{ path('fos_user_security_login') }}',
|
||||
locale: '{{ app.request.locale }}',
|
||||
first_dow_iso: {{ iso_day_by_name(app.user.firstDayOfWeek) }},
|
||||
twentyFourHours: {{ app.user.is24Hour() ? 'true' : 'false' }},
|
||||
autoComplete: {{ kimai_config.themeAutocompleteCharacters }},
|
||||
defaultColor: '{{ constant('App\\Constants::DEFAULT_COLOR') }}',
|
||||
updateBrowserTitle: {% if app.user.preferenceValue('theme.update_browser_title') %}true{% else %}false{% endif %}
|
||||
},
|
||||
{
|
||||
{% for key, translation in javascript_translations() -%}
|
||||
'{{ key }}': '{{ translation.0|trans({}, translation.1)|escape('js') }}',
|
||||
{%- endfor %}
|
||||
}
|
||||
);
|
||||
window.kimai = loader.getKimai();
|
||||
});
|
||||
</script>
|
||||
{% import "macros/webloader.html.twig" as webloader %}
|
||||
{{ webloader.init_frontend_loader() }}
|
||||
{% set event = trigger(constant('App\\Event\\ThemeEvent::JAVASCRIPT')) %}
|
||||
{{ event.content|raw }}
|
||||
{% endblock %}
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% for button in group %}
|
||||
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ (button.id)|trans({}, 'export') }}</a></li>
|
||||
{% set btnTitle = (button.id)|trans({}, 'export') %}
|
||||
{% if btnTitle == button.id %}
|
||||
{% set btnTitle = button.id|split('.')|first|replace({'-': ' ', '_': ' '})|split(' ')|map(t => t|capitalize)|join(' ') %}
|
||||
{% endif %}
|
||||
<li><a href="#" class="startExportBtn" data-type="{{ button.id }}">{{ btnTitle }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'export/layout.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% extends 'export/layout.html.twig' %}
|
||||
{% import "macros/webloader.html.twig" as webloader %}
|
||||
|
||||
{% block document_title %}{{ 'export.title'|trans }}{% endblock %}
|
||||
|
||||
@@ -51,27 +52,25 @@
|
||||
}) %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ webloader.init_frontend_loader() }}
|
||||
<script type="text/javascript">
|
||||
let initialized = false;
|
||||
|
||||
window.addEventListener('load', function() {
|
||||
var loader = new KimaiWebLoader({
|
||||
formatDuration: '{{ get_format_duration() }}',
|
||||
locale: '{{ app.request.locale }}',
|
||||
}, {});
|
||||
var DATES = loader.getKimai().getPlugin('date');
|
||||
document.addEventListener('kimai.initialized', function(event) {
|
||||
const KIMAI = event.detail.kimai;
|
||||
const DATES = KIMAI.getPlugin('date');
|
||||
|
||||
document.getElementById('duration-decimal').addEventListener('click', function(event) {
|
||||
var spans = document.getElementsByClassName('duration-format');
|
||||
for (var span of spans) {
|
||||
var duration = span.dataset['duration'];
|
||||
let spans = document.getElementsByClassName('duration-format');
|
||||
for (let span of spans) {
|
||||
let duration = span.dataset['duration'];
|
||||
if (duration === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!event.target.checked) {
|
||||
span.innerHTML = DATES.formatSeconds(duration);
|
||||
} else {
|
||||
span.innerHTML = (new Intl.NumberFormat('{{ app.request.locale }}', {maximumFractionDigits: 2}).format(duration / 3600));
|
||||
span.innerHTML = (new Intl.NumberFormat('{{ app.request.locale|replace({'_': '-'}) }}', {maximumFractionDigits: 2}).format(duration / 3600));
|
||||
}
|
||||
}
|
||||
saveVisibility();
|
||||
@@ -96,36 +95,36 @@
|
||||
saveVisibility();
|
||||
});
|
||||
document.getElementById('summary-timeBudget').addEventListener('click', function(event) {
|
||||
var cells = document.getElementsByClassName('export-timeBudget');
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('export-timeBudget');
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
});
|
||||
document.getElementById('summary-budget').addEventListener('click', function(event) {
|
||||
var cells = document.getElementsByClassName('export-budget');
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('export-budget');
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
});
|
||||
document.getElementById('summary-duration').addEventListener('click', function(event) {
|
||||
var cells = document.getElementsByClassName('summary-duration');
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('summary-duration');
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
});
|
||||
document.getElementById('summary-rate').addEventListener('click', function(event) {
|
||||
var cells = document.getElementsByClassName('summary-rate');
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('summary-rate');
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
});
|
||||
document.getElementById('summary-rate-internal').addEventListener('click', function(event) {
|
||||
var cells = document.getElementsByClassName('summary-rate-internal');
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('summary-rate-internal');
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = event.target.checked ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
@@ -139,17 +138,17 @@
|
||||
saveVisibility();
|
||||
});
|
||||
|
||||
var columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
|
||||
let columnCheckboxes = document.getElementsByClassName('column-visibility-changer');
|
||||
|
||||
for (var checkbox of columnCheckboxes) {
|
||||
for (let checkbox of columnCheckboxes) {
|
||||
checkbox.addEventListener('click', function(event) {
|
||||
changeVisibility(event.target.name, event.target.checked);
|
||||
});
|
||||
}
|
||||
|
||||
var editableTitles = document.querySelectorAll('[contenteditable=true]');
|
||||
let editableTitles = document.querySelectorAll('[contenteditable=true]');
|
||||
|
||||
for (var editable of editableTitles) {
|
||||
for (let editable of editableTitles) {
|
||||
editable.addEventListener('input', function(event) {
|
||||
if (event.target.innerText === '') {
|
||||
return;
|
||||
@@ -200,8 +199,8 @@
|
||||
|
||||
function changeVisibility(column, visible)
|
||||
{
|
||||
var cells = document.getElementsByClassName('column-' + column);
|
||||
for (var columnCell of cells) {
|
||||
let cells = document.getElementsByClassName('column-' + column);
|
||||
for (let columnCell of cells) {
|
||||
columnCell.style.display = visible ? 'table-cell' : 'none';
|
||||
}
|
||||
saveVisibility();
|
||||
@@ -209,9 +208,9 @@
|
||||
|
||||
function changedDateFormat(formatType, className)
|
||||
{
|
||||
var elements = document.getElementsByClassName(className);
|
||||
for (var elem of elements) {
|
||||
var formattedDate = elem.dataset[formatType];
|
||||
let elements = document.getElementsByClassName(className);
|
||||
for (let elem of elements) {
|
||||
let formattedDate = elem.dataset[formatType];
|
||||
if (formattedDate === undefined) {
|
||||
continue;
|
||||
}
|
||||
@@ -242,9 +241,9 @@
|
||||
|
||||
function resetExport()
|
||||
{
|
||||
var editableTitles = document.querySelectorAll('[contenteditable=true]');
|
||||
let editableTitles = document.querySelectorAll('[contenteditable=true]');
|
||||
|
||||
for (var editable of editableTitles) {
|
||||
for (let editable of editableTitles) {
|
||||
editable.innerText = editable.dataset['original'];
|
||||
}
|
||||
|
||||
@@ -252,7 +251,6 @@
|
||||
const form = document.getElementsByTagName('form')[0];
|
||||
form.reset();
|
||||
}
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
31
templates/macros/webloader.html.twig
Normal file
31
templates/macros/webloader.html.twig
Normal file
@@ -0,0 +1,31 @@
|
||||
{% macro init_frontend_loader() %}
|
||||
{% set configurations = javascript_configurations(app.user)|merge({
|
||||
login: path('fos_user_security_login'),
|
||||
locale: app.request.locale,
|
||||
first_dow_iso: iso_day_by_name(app.user.firstDayOfWeek),
|
||||
autoComplete: kimai_config.themeAutocompleteCharacters,
|
||||
}) %}
|
||||
<script type="text/javascript">
|
||||
window.addEventListener('load', function() {
|
||||
const loader = new KimaiWebLoader({
|
||||
{%- for name, value in configurations -%}
|
||||
{%- if value is same as (true) -%}
|
||||
{{ name }}:true
|
||||
{%- elseif value is same as (false) -%}
|
||||
{{ name }}:false
|
||||
{%- elseif value is number -%}
|
||||
{{ name }}:{{ value }}
|
||||
{%- else -%}
|
||||
{{ name }}:'{{ value }}'
|
||||
{%- endif -%}
|
||||
{%- if not loop.last %},{% endif -%}
|
||||
{%- endfor -%}
|
||||
},{
|
||||
{%- for key, translation in javascript_translations() -%}
|
||||
'{{ key }}': '{{ translation.0|trans({}, translation.1)|escape('js') }}'{% if not loop.last %},{% endif %}
|
||||
{%- endfor -%}
|
||||
});
|
||||
window.kimai = loader.getKimai();
|
||||
});
|
||||
</script>
|
||||
{% endmacro %}
|
||||
@@ -7,7 +7,7 @@
|
||||
{% embed '@AdminLTE/Widgets/box-widget.html.twig' %}
|
||||
{% from "macros/widgets.html.twig" import nothing_found %}
|
||||
{% block box_before %}
|
||||
{{ form_start(form, {'attr': {'class': 'form-inline kimai-1.17', 'id': 'user-list-filter-form'}}) }}
|
||||
{{ form_start(form, {'attr': {'class': 'form-inline', 'id': 'user-list-filter-form'}}) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}
|
||||
{{ form_end(form) }}
|
||||
|
||||
@@ -43,22 +43,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.customer is defined %}
|
||||
{{ form_row(form.customer) }}
|
||||
{{ form_row(form.customer, {'row_attr': {'class': 'timesheet_edit_form_row_customer'}}) }}
|
||||
{% endif %}
|
||||
{% if form.project is defined %}
|
||||
{{ form_row(form.project) }}
|
||||
{{ form_row(form.project, {'row_attr': {'class': 'timesheet_edit_form_row_project'}}) }}
|
||||
{% endif %}
|
||||
{% if form.activity is defined %}
|
||||
{{ form_row(form.activity) }}
|
||||
{{ form_row(form.activity, {'row_attr': {'class': 'timesheet_edit_form_row_activity'}}) }}
|
||||
{% endif %}
|
||||
{% if form.description is defined %}
|
||||
{{ form_row(form.description) }}
|
||||
{{ form_row(form.description, {'row_attr': {'class': 'timesheet_edit_form_row_description'}}) }}
|
||||
{% endif %}
|
||||
{% if form.tags is defined %}
|
||||
{{ form_row(form.tags) }}
|
||||
{{ form_row(form.tags, {'row_attr': {'class': 'timesheet_edit_form_row_tags'}}) }}
|
||||
{% endif %}
|
||||
{% if form.user is defined %}
|
||||
{{ form_row(form.user) }}
|
||||
{{ form_row(form.user, {'row_attr': {'class': 'timesheet_edit_form_row_user'}}) }}
|
||||
{% endif %}
|
||||
{% if form.users is defined or form.teams is defined %}
|
||||
{% set uLength = 12 %}
|
||||
@@ -81,10 +81,10 @@
|
||||
{% if form.fixedRate is defined and form.hourlyRate is defined %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.fixedRate) }}
|
||||
{{ form_row(form.fixedRate, {'row_attr': {'class': 'timesheet_edit_form_row_fixedRate'}}) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_row(form.hourlyRate) }}
|
||||
{{ form_row(form.hourlyRate, {'row_attr': {'class': 'timesheet_edit_form_row_hourlyRate'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -98,12 +98,12 @@
|
||||
<div class="row">
|
||||
{% if form.exported is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
{{ form_row(form.exported) }}
|
||||
{{ form_row(form.exported, {'row_attr': {'class': 'timesheet_edit_form_row_exported'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.billable is defined %}
|
||||
<div class="col-md-{{ ebLength }}">
|
||||
{{ form_row(form.billable) }}
|
||||
{{ form_row(form.billable, {'row_attr': {'class': 'timesheet_edit_form_row_billable'}}) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -223,8 +223,9 @@ class ActivityControllerTest extends ControllerBaseTest
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=activity_edit_form]')->form();
|
||||
$this->assertTrue($form->has('activity_edit_form[metaFields][0][value]'));
|
||||
$this->assertFalse($form->has('activity_edit_form[metaFields][1][value]'));
|
||||
$this->assertTrue($form->has('activity_edit_form[metaFields][metatestmock][value]'));
|
||||
$this->assertTrue($form->has('activity_edit_form[metaFields][foobar][value]'));
|
||||
$this->assertFalse($form->has('activity_edit_form[metaFields][0][value]'));
|
||||
}
|
||||
|
||||
public function testEditAction()
|
||||
|
||||
@@ -367,8 +367,9 @@ class CustomerControllerTest extends ControllerBaseTest
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=customer_edit_form]')->form();
|
||||
$this->assertTrue($form->has('customer_edit_form[metaFields][0][value]'));
|
||||
$this->assertFalse($form->has('customer_edit_form[metaFields][1][value]'));
|
||||
$this->assertTrue($form->has('customer_edit_form[metaFields][metatestmock][value]'));
|
||||
$this->assertTrue($form->has('customer_edit_form[metaFields][foobar][value]'));
|
||||
$this->assertFalse($form->has('customer_edit_form[metaFields][0][value]'));
|
||||
}
|
||||
|
||||
public function testEditAction()
|
||||
|
||||
@@ -435,8 +435,9 @@ class ProjectControllerTest extends ControllerBaseTest
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=project_edit_form]')->form();
|
||||
$this->assertTrue($form->has('project_edit_form[metaFields][0][value]'));
|
||||
$this->assertFalse($form->has('project_edit_form[metaFields][1][value]'));
|
||||
$this->assertTrue($form->has('project_edit_form[metaFields][metatestmock][value]'));
|
||||
$this->assertTrue($form->has('project_edit_form[metaFields][foobar][value]'));
|
||||
$this->assertFalse($form->has('project_edit_form[metaFields][0][value]'));
|
||||
}
|
||||
|
||||
public function testEditAction()
|
||||
|
||||
@@ -257,8 +257,9 @@ class TimesheetControllerTest extends ControllerBaseTest
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
|
||||
$this->assertTrue($form->has('timesheet_edit_form[metaFields][0][value]'));
|
||||
$this->assertFalse($form->has('timesheet_edit_form[metaFields][1][value]'));
|
||||
$this->assertTrue($form->has('timesheet_edit_form[metaFields][metatestmock][value]'));
|
||||
$this->assertTrue($form->has('timesheet_edit_form[metaFields][foobar][value]'));
|
||||
$this->assertFalse($form->has('timesheet_edit_form[metaFields][0][value]'));
|
||||
}
|
||||
|
||||
public function testCreateActionDoesNotShowRateFieldsForUser()
|
||||
|
||||
143
tests/Invoice/Calculator/PriceInvoiceCalculatorTest.php
Normal file
143
tests/Invoice/Calculator/PriceInvoiceCalculatorTest.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?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\Calculator;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Invoice\Calculator\DateInvoiceCalculator;
|
||||
use App\Invoice\Calculator\PriceInvoiceCalculator;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Tests\Invoice\DebugFormatter;
|
||||
use App\Tests\Mocks\InvoiceModelFactoryFactory;
|
||||
use DateTime;
|
||||
|
||||
/**
|
||||
* @covers \App\Invoice\Calculator\PriceInvoiceCalculator
|
||||
* @covers \App\Invoice\Calculator\AbstractSumInvoiceCalculator
|
||||
* @covers \App\Invoice\Calculator\AbstractMergedCalculator
|
||||
* @covers \App\Invoice\Calculator\AbstractCalculator
|
||||
*/
|
||||
class PriceInvoiceCalculatorTest extends AbstractCalculatorTest
|
||||
{
|
||||
public function testEmptyModel()
|
||||
{
|
||||
$this->assertEmptyModel(new DateInvoiceCalculator());
|
||||
}
|
||||
|
||||
public function testWithMultipleEntries()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setVat(19);
|
||||
|
||||
$user = $this->getMockBuilder(User::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
|
||||
$user->method('getId')->willReturn(1);
|
||||
|
||||
$project1 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
|
||||
$project1->method('getId')->willReturn(1);
|
||||
|
||||
$project2 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
|
||||
$project2->method('getId')->willReturn(2);
|
||||
|
||||
$project3 = $this->getMockBuilder(Project::class)->onlyMethods(['getId'])->disableOriginalConstructor()->getMock();
|
||||
$project3->method('getId')->willReturn(3);
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setBegin(new DateTime('2018-11-29'))
|
||||
->setEnd(new DateTime())
|
||||
->setDuration(3600)
|
||||
->setHourlyRate(293.27)
|
||||
->setRate(293.27)
|
||||
->setUser($user)
|
||||
->setActivity((new Activity())->setName('sdsd'))
|
||||
->setProject($project1);
|
||||
|
||||
$timesheet2 = new Timesheet();
|
||||
$timesheet2
|
||||
->setBegin(new DateTime('2018-11-29'))
|
||||
->setEnd(new DateTime())
|
||||
->setDuration(400)
|
||||
->setHourlyRate(293.27)
|
||||
->setRate(84.75)
|
||||
->setUser($user)
|
||||
->setActivity((new Activity())->setName('bar'))
|
||||
->setProject($project2);
|
||||
|
||||
$timesheet3 = new Timesheet();
|
||||
$timesheet3
|
||||
->setBegin(new DateTime('2018-11-28'))
|
||||
->setEnd(new DateTime())
|
||||
->setDuration(1800)
|
||||
->setFixedRate(111.11)
|
||||
->setRate(111.11)
|
||||
->setUser($user)
|
||||
->setActivity((new Activity())->setName('foo'))
|
||||
->setProject($project1);
|
||||
|
||||
$timesheet4 = new Timesheet();
|
||||
$timesheet4
|
||||
->setBegin(new DateTime())
|
||||
->setEnd(new DateTime('2018-11-28'))
|
||||
->setDuration(400)
|
||||
->setHourlyRate(0)
|
||||
->setRate(1947.99)
|
||||
->setUser($user)
|
||||
->setActivity((new Activity())->setName('blub'))
|
||||
->setProject($project2);
|
||||
|
||||
$timesheet5 = new Timesheet();
|
||||
$timesheet5
|
||||
->setBegin(new DateTime('2018-11-28'))
|
||||
->setEnd(new DateTime())
|
||||
->setDuration(400)
|
||||
->setRate(84)
|
||||
->setUser(new User())
|
||||
->setActivity(new Activity())
|
||||
->setProject($project3);
|
||||
|
||||
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setProjects([$project1]);
|
||||
|
||||
$model = (new InvoiceModelFactoryFactory($this))->create()->createModel(new DebugFormatter());
|
||||
$model->setCustomer($customer);
|
||||
$model->setTemplate($template);
|
||||
$model->addEntries($entries);
|
||||
$model->setQuery($query);
|
||||
|
||||
$sut = new PriceInvoiceCalculator();
|
||||
$sut->setModel($model);
|
||||
|
||||
$this->assertEquals('price', $sut->getId());
|
||||
$this->assertEquals(3000.13, $sut->getTotal());
|
||||
$this->assertEquals(19, $sut->getVat());
|
||||
$this->assertEquals('EUR', $model->getCurrency());
|
||||
$this->assertEquals(2521.12, $sut->getSubtotal());
|
||||
$this->assertEquals(4800, $sut->getTimeWorked());
|
||||
|
||||
$entries = $sut->getEntries();
|
||||
self::assertCount(4, $entries);
|
||||
$this->assertEquals(378.02, $entries[0]->getRate());
|
||||
$this->assertEquals(111.11, $entries[1]->getRate());
|
||||
$this->assertEquals(1947.99, $entries[2]->getRate());
|
||||
$this->assertEquals(84, $entries[3]->getRate());
|
||||
}
|
||||
|
||||
public function testDescriptionByTimesheet()
|
||||
{
|
||||
$this->assertDescription(new PriceInvoiceCalculator(), false, false);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\Mocks;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Event\ActivityMetaDefinitionEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -33,5 +34,12 @@ class ActivityTestMetaFieldSubscriberMock implements EventSubscriberInterface
|
||||
->setIsVisible(true);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
|
||||
$definition = (new ActivityMeta())
|
||||
->setName('foobar')
|
||||
->setType(IntegerType::class)
|
||||
->setIsVisible(false);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\Mocks;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Event\CustomerMetaDefinitionEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -33,5 +34,12 @@ class CustomerTestMetaFieldSubscriberMock implements EventSubscriberInterface
|
||||
->setIsVisible(true);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
|
||||
$definition = (new CustomerMeta())
|
||||
->setName('foobar')
|
||||
->setType(IntegerType::class)
|
||||
->setIsVisible(false);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\Mocks;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Event\ProjectMetaDefinitionEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -33,5 +34,12 @@ class ProjectTestMetaFieldSubscriberMock implements EventSubscriberInterface
|
||||
->setIsVisible(true);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
|
||||
$definition = (new ProjectMeta())
|
||||
->setName('foobar')
|
||||
->setType(IntegerType::class)
|
||||
->setIsVisible(false);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\Mocks;
|
||||
use App\Entity\TimesheetMeta;
|
||||
use App\Event\TimesheetMetaDefinitionEvent;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Validator\Constraints\Length;
|
||||
|
||||
@@ -33,5 +34,11 @@ class TimesheetTestMetaFieldSubscriberMock implements EventSubscriberInterface
|
||||
->setIsVisible(true);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
$definition = (new TimesheetMeta())
|
||||
->setName('foobar')
|
||||
->setType(IntegerType::class)
|
||||
->setIsVisible(false);
|
||||
|
||||
$event->getEntity()->setMetaField($definition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Twig\Node\Node;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
|
||||
/**
|
||||
* @covers \App\Twig\Extensions
|
||||
@@ -28,6 +29,18 @@ class ExtensionsTest extends TestCase
|
||||
return new Extensions();
|
||||
}
|
||||
|
||||
private function getTest(string $name): TwigTest
|
||||
{
|
||||
$sut = $this->getSut();
|
||||
foreach ($sut->getTests() as $test) {
|
||||
if ($test->getName() === $name) {
|
||||
return $test;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \Exception('Unknown twig test: ' . $name);
|
||||
}
|
||||
|
||||
public function testGetFilters()
|
||||
{
|
||||
$filters = ['report_date', 'docu_link', 'multiline_indent', 'color', 'font_contrast', 'default_color', 'nl2str'];
|
||||
@@ -63,6 +76,22 @@ class ExtensionsTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testGetTests()
|
||||
{
|
||||
$tests = ['number'];
|
||||
$i = 0;
|
||||
|
||||
$sut = $this->getSut();
|
||||
$twigTests = $sut->getTests();
|
||||
$this->assertCount(\count($tests), $twigTests);
|
||||
|
||||
/** @var TwigTest $test */
|
||||
foreach ($twigTests as $test) {
|
||||
$this->assertInstanceOf(TwigTest::class, $test);
|
||||
$this->assertEquals($tests[$i++], $test->getName());
|
||||
}
|
||||
}
|
||||
|
||||
public function testDocuLink()
|
||||
{
|
||||
$data = [
|
||||
@@ -213,6 +242,17 @@ sdfsdf' . PHP_EOL . "\n" .
|
||||
self::assertEquals('', $sut->defaultColor(''));
|
||||
}
|
||||
|
||||
public function testIsNumeric()
|
||||
{
|
||||
$test = $this->getTest('number');
|
||||
self::assertFalse(\call_user_func($test->getCallable(), null));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), true));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), false));
|
||||
self::assertFalse(\call_user_func($test->getCallable(), '1'));
|
||||
self::assertTrue(\call_user_func($test->getCallable(), 1));
|
||||
self::assertTrue(\call_user_func($test->getCallable(), 1.0));
|
||||
}
|
||||
|
||||
private static function assertIsValidColor(string $color)
|
||||
{
|
||||
self::assertStringStartsWith('#', $color);
|
||||
|
||||
@@ -27,10 +27,10 @@ use Twig\TwigTest;
|
||||
*/
|
||||
class LocaleFormatExtensionsTest extends TestCase
|
||||
{
|
||||
private $localeEn = ['en' => ['date' => 'Y-m-d', 'duration' => '%h:%m h']];
|
||||
private $localeDe = ['de' => ['date' => 'd.m.Y', 'duration' => '%h:%m h']];
|
||||
private $localeRu = ['ru' => ['date' => 'd.m.Y', 'duration' => '%h:%m h']];
|
||||
private $localeFake = ['XX' => ['date' => 'd.m.Y', 'duration' => '%h - %m - %s Zeit']];
|
||||
private $localeEn = ['en' => ['date' => 'Y-m-d', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeDe = ['de' => ['date' => 'd.m.Y', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeRu = ['ru' => ['date' => 'd.m.Y', 'duration' => '%h:%m h', 'date_type' => 'yyyy-MM-dd']];
|
||||
private $localeFake = ['XX' => ['date' => 'd.m.Y', 'duration' => '%h - %m - %s Zeit', 'date_type' => 'yyyy-MM-dd']];
|
||||
|
||||
/**
|
||||
* @param string|array $locale
|
||||
@@ -74,7 +74,7 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
|
||||
public function testGetFunctions()
|
||||
{
|
||||
$functions = ['get_format_duration', 'create_date', 'locales', 'month_names'];
|
||||
$functions = ['javascript_configurations', 'get_format_duration', 'create_date', 'locales', 'month_names'];
|
||||
$i = 0;
|
||||
|
||||
$sut = $this->getSut('de', []);
|
||||
@@ -479,6 +479,19 @@ class LocaleFormatExtensionsTest extends TestCase
|
||||
self::assertEquals(342.94, $sut->durationChart(1234567));
|
||||
}
|
||||
|
||||
public function testJavascriptConfigurations()
|
||||
{
|
||||
$expected = [
|
||||
'formatDuration' => '%h:%m h',
|
||||
'formatDate' => 'YYYY-MM-DD',
|
||||
'defaultColor' => '#d2d6de',
|
||||
'twentyFourHours' => true,
|
||||
'updateBrowserTitle' => false,
|
||||
];
|
||||
$sut = $this->getSut('en', $this->localeEn);
|
||||
self::assertEquals($expected, $sut->getJavascriptConfiguration(new User()));
|
||||
}
|
||||
|
||||
public function testDurationDecimal()
|
||||
{
|
||||
$record = $this->getTimesheet(9437);
|
||||
|
||||
@@ -81,6 +81,8 @@ class LocaleHelperTest extends TestCase
|
||||
$this->assertEquals('123.234,76', $sut->money(123234.7554, null, true));
|
||||
$this->assertEquals('123.234,76', $sut->money(123234.7554, null, false));
|
||||
$this->assertEquals('123.234,76', $sut->money(123234.7554, 'EUR', false));
|
||||
$this->assertEquals('-123.234,76', $sut->money(-123234.7554, null, false));
|
||||
$this->assertEquals('-123.234,76', $sut->money(-123234.7554, 'EUR', false));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +107,7 @@ class LocaleHelperTest extends TestCase
|
||||
['13,75', 13.75, 'RUB', 'de'],
|
||||
['13,75', 13.75, 'JPY', 'de'],
|
||||
['13 933,49', 13933.49, 'JPY', 'ru'],
|
||||
['-13 933,49', -13933.49, 'JPY', 'ru'],
|
||||
['13,75', 13.75, 'CNY', 'de'],
|
||||
['13.933,00', 13933, 'CNY', 'de'],
|
||||
['13 933,00', 13933, 'CNY', 'ru'],
|
||||
@@ -141,6 +144,8 @@ class LocaleHelperTest extends TestCase
|
||||
['13 933,00 CN¥', 13933, 'CNY', 'ru'],
|
||||
['CN¥13,933.00', 13933, 'CNY', 'en'],
|
||||
['1.234.567,89 $', 1234567.891234567890000, 'USD', 'de'],
|
||||
['-CN¥13,933.00', -13933, 'CNY', 'en'],
|
||||
['-1.234.567,89 $', -1234567.891234567890000, 'USD', 'de'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="fr" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Par défaut</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="hr" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Standardno</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="pt" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Padrão</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="pt-BR" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Padrão</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="ro" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Implicit</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="tr" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>Öntanımlı</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||
<file source-language="en" target-language="zh-CN" datatype="plaintext" original="export.en.xlf">
|
||||
<body>
|
||||
<trans-unit id="oRRnhwf" resname="default.pdf.twig">
|
||||
<trans-unit id="IbERy.5" resname="default.pdf.twig">
|
||||
<source>Default</source>
|
||||
<target>默认</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<source>weekly</source>
|
||||
<target>Wöchentlich: ein Eintrag pro Woche (verwendet Startdatum)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="aDtTH0H" resname="price">
|
||||
<source>price</source>
|
||||
<target>Preis: ein Eintrag je Preis</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<source>weekly</source>
|
||||
<target>Weekly: one entry per week (uses start date)</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="aDtTH0H" resname="price">
|
||||
<source>price</source>
|
||||
<target>Price: one entry per price</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<source>modal.dirty</source>
|
||||
<target>Das Formular wurde geändert. Bitte klicken Sie „Speichern“, um die Änderungen zu sichern oder „Schließen“, um abzubrechen.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sdfgsdfgsdfg" resname="label.choice_pattern">
|
||||
<trans-unit id="Yxxu2tG" resname="label.choice_pattern">
|
||||
<source>Display of entries in selection lists</source>
|
||||
<target>Darstellung der Einträge in Auswahllisten</target>
|
||||
</trans-unit>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<source>modal.dirty</source>
|
||||
<target>The form has changed. Please click "Save" to save the changes or "Close" to cancel.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sdfgsdfgsdfg" resname="label.choice_pattern">
|
||||
<trans-unit id="Yxxu2tG" resname="label.choice_pattern">
|
||||
<source>Display of entries in selection lists</source>
|
||||
<target>Display of entries in selection lists</target>
|
||||
</trans-unit>
|
||||
|
||||
Reference in New Issue
Block a user