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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user