Compare commits

...

14 Commits

Author SHA1 Message Date
Kevin Papst
c341d340e6 fix html injection 2023-03-03 17:55:18 +01:00
Kevin Papst
777e3ec008 bump version 2023-03-03 17:54:59 +01:00
Kevin Papst
6df135d547 fixed replacer cannot find value for field (#3862) 2023-02-21 10:42:25 +01:00
Kevin Papst
a5109781fa allow to overwrite styles in excel export 2023-02-19 15:57:50 +01:00
Kevin Papst
2d809b4f06 Release 1.30.8 (#3854)
* fix weekly timesheets can delete wrong records
* pin phpunit to prevent deprecations
2023-02-17 21:06:05 +01:00
Kevin Papst
ced1883b6f activate deprecation logs in dev and test 2023-02-09 13:02:33 +01:00
Kevin Papst
2e54a9a267 bump version to 1.30.7 2023-02-09 13:01:36 +01:00
Kevin Papst
57722f25fd fix call on null 2023-02-06 15:57:30 +01:00
Kevin Papst
539a7e280c clarify requirement in option description 2023-02-06 15:56:48 +01:00
Kevin Papst
db4179df19 release 1.30.5 (#3783) 2023-01-20 12:55:50 +01:00
Kevin Papst
7ea3eb1e3c Release 1.30.4 (#3770)
* prevent empty invoice number
* prevent broken invoice filename
* fix recent timesheets for user
* allow to upload twig invoice template
2023-01-19 15:13:44 +01:00
Kevin Papst
559868fede Release 1.30.3 (#3763)
* fix calendar title for project - customer
* allow to pre-fill timesheet metafields via URL
2023-01-15 17:51:07 +01:00
Kevin Papst
d74236d9ac added ukrainian (#3744) 2023-01-10 00:23:26 +01:00
Kevin Papst
30bc3653d7 do not traverse into invoice template subdirectories (#3735) 2023-01-05 15:51:21 +01:00
70 changed files with 1043 additions and 892 deletions

View File

@@ -39,6 +39,7 @@ require('select2/dist/js/i18n/ru');
require('select2/dist/js/i18n/sk');
require('select2/dist/js/i18n/sv');
require('select2/dist/js/i18n/tr');
require('select2/dist/js/i18n/uk');
require('select2/dist/js/i18n/vi');
require('select2/dist/js/i18n/zh-CN');
@@ -74,6 +75,7 @@ require('moment/locale/ru');
require('moment/locale/sk');
require('moment/locale/sv');
require('moment/locale/tr');
require('moment/locale/uk');
require('moment/locale/vi');
require('moment/locale/zh-cn');

View File

@@ -33,6 +33,7 @@ require('fullcalendar/dist/locale/ru');
require('fullcalendar/dist/locale/sk');
require('fullcalendar/dist/locale/sv');
require('fullcalendar/dist/locale/tr');
require('fullcalendar/dist/locale/uk');
require('fullcalendar/dist/locale/zh-cn');
require('fullcalendar/dist/locale/vi');
require('fullcalendar/dist/locale/en-gb'); // the last imported file is used as fallback for locales that do not exist (like EO)

View File

@@ -86,8 +86,8 @@
"phpstan/phpstan": "^1.0",
"phpstan/phpstan-doctrine": "^1.0",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-symfony": "^1.0",
"phpunit/phpunit": "^9.0",
"phpstan/phpstan-symfony": "1.2.18",
"phpunit/phpunit": "9.5.*",
"symfony/browser-kit": "^4.4",
"symfony/css-selector": "^4.4",
"symfony/debug-bundle": "^4.4",

945
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,3 +17,11 @@ monolog:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
deprecation:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"
deprecation_filter:
type: filter
handler: deprecation
max_level: info
channels: ["php"]

View File

@@ -5,3 +5,11 @@ monolog:
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
deprecation:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.deprecations.log"
deprecation_filter:
type: filter
handler: deprecation
max_level: info
channels: ["php"]

View File

@@ -2,7 +2,7 @@
# https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration
parameters:
locale: en
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|en_GB|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
app_locales: ar|cs|da|de|de_AT|de_CH|el|en|en_GB|eo|es|eu|fa|fi|fo|fr|he|hr|hu|it|ja|ko|nb_NO|nl|pl|pt|pt_BR|ro|ru|sk|sv|tr|uk|vi|zh_CN
services:
# default configuration for services in *this* file

View File

@@ -36,8 +36,8 @@
"jquery-ui": "^1.12",
"js-cookie": "^2.2",
"moment": "^2.29",
"node-sass": "^7.0",
"sass-loader": "^11.0",
"node-sass": "^8.0",
"sass-loader": "^13.2",
"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

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,7 +3,7 @@
"app": {
"js": [
"build/runtime.b8e7bb04.js",
"build/app.74a74ff6.js"
"build/app.adae4b3b.js"
],
"css": [
"build/app.dd7cccf4.css"
@@ -36,7 +36,7 @@
"calendar": {
"js": [
"build/runtime.b8e7bb04.js",
"build/calendar.32dd2acd.js"
"build/calendar.d31fcf6a.js"
],
"css": [
"build/calendar.25e7e872.css"

View File

@@ -1,13 +1,13 @@
{
"build/app.css": "build/app.dd7cccf4.css",
"build/app.js": "build/app.74a74ff6.js",
"build/app.js": "build/app.adae4b3b.js",
"build/invoice.css": "build/invoice.ccdecd42.css",
"build/invoice.js": "build/invoice.19f36eca.js",
"build/invoice-pdf.css": "build/invoice-pdf.e73a6dda.css",
"build/invoice-pdf.js": "build/invoice-pdf.0ce1f3ce.js",
"build/chart.js": "build/chart.e845e930.js",
"build/calendar.css": "build/calendar.25e7e872.css",
"build/calendar.js": "build/calendar.32dd2acd.js",
"build/calendar.js": "build/calendar.d31fcf6a.js",
"build/runtime.js": "build/runtime.b8e7bb04.js",
"build/images/fa-solid-900.svg": "build/images/fa-solid-900.13de59f1.svg",
"build/images/fa-brands-400.svg": "build/images/fa-brands-400.216edb96.svg",

View File

@@ -21,6 +21,7 @@ use App\Form\API\TimesheetApiEditForm;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TagRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\TimesheetService;
use App\Timesheet\TrackingMode\TrackingModeInterface;
use App\Utils\SearchTerm;
@@ -75,19 +76,22 @@ class TimesheetController extends BaseApiController
* @var TimesheetService
*/
private $service;
private $userRepository;
public function __construct(
ViewHandlerInterface $viewHandler,
TimesheetRepository $repository,
TagRepository $tagRepository,
EventDispatcherInterface $dispatcher,
TimesheetService $service
TimesheetService $service,
UserRepository $userRepository
) {
$this->viewHandler = $viewHandler;
$this->repository = $repository;
$this->tagRepository = $tagRepository;
$this->dispatcher = $dispatcher;
$this->service = $service;
$this->userRepository = $userRepository;
}
protected function getTrackingMode(): TrackingModeInterface
@@ -491,9 +495,13 @@ class TimesheetController extends BaseApiController
if ($this->isGranted('view_other_timesheet') && null !== ($reqUser = $paramFetcher->get('user'))) {
if ('all' === $reqUser) {
$reqUser = null;
$user = null;
} else {
$user = $this->userRepository->getUserById($reqUser);
if ($user === null) {
throw $this->createNotFoundException('Unknown User ID');
}
}
$user = $reqUser;
}
if (null !== ($reqLimit = $paramFetcher->get('size'))) {

View File

@@ -104,6 +104,10 @@ class ActivityService
public function findActivityByName(string $name, ?Project $project = null): ?Activity
{
if ($project === null) {
return $this->repository->findOneBy(['project' => null, 'name' => $name]);
}
return $this->repository->findOneBy(['project' => $project->getId(), 'name' => $name]);
}
}

View File

@@ -76,7 +76,7 @@ class ExportCreateCommand extends Command
->addOption('customer', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Customer IDs to filter', null)
->addOption('project', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Project IDs to filter', null)
->addOption('team', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Team IDs to filter', null)
->addOption('user', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'User IDs to filter', null)
->addOption('user', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'User IDs to filter ("username" needs to be in the same Team)', null)
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the included items should be marked as exported (default: false)')
->addOption('template', null, InputOption::VALUE_REQUIRED, 'Export template', null)
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for export entries. By default only "not exported" items are fetched (possible values: exported, all)', null)

View File

@@ -1579,7 +1579,7 @@ final class KimaiImporterCommand extends Command
continue;
}
if (empty($oldRecord['end']) || $oldRecord['end'] === 0) {
if (empty($oldRecord['end'])) {
$io->error('Cannot import running timesheet record, skipping: ' . $oldRecord['timeEntryID']);
$failed++;
continue;

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.30.1';
public const VERSION = '1.30.11';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 13001;
public const VERSION_ID = 13011;
/**
* The current release status, either "stable" or "dev"
*/

View File

@@ -44,6 +44,7 @@ use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Twig\Environment;
/**
* Controller used to create invoices and manage invoice templates.
@@ -409,7 +410,7 @@ final class InvoiceController extends AbstractController
* @Route(path="/document_upload", name="admin_invoice_document_upload", methods={"GET", "POST"})
* @Security("is_granted('upload_invoice_template')")
*/
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository)
public function uploadDocumentAction(Request $request, string $projectDirectory, InvoiceDocumentRepository $documentRepository, Environment $twig, SystemConfiguration $systemConfiguration)
{
$dir = $documentRepository->getUploadDirectory();
$invoiceDir = $dir;
@@ -418,6 +419,7 @@ final class InvoiceController extends AbstractController
if ($invoiceDir[0] !== '/') {
$invoiceDir = $projectDirectory . DIRECTORY_SEPARATOR . $dir;
}
$invoiceDir = rtrim($invoiceDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$used = [];
foreach ($this->templateRepository->findAll() as $template) {
@@ -473,23 +475,56 @@ final class InvoiceController extends AbstractController
/** @var UploadedFile $uploadedFile */
$uploadedFile = $form->get('document')->getData();
$originalFilename = pathinfo($uploadedFile->getClientOriginalName(), PATHINFO_FILENAME);
$safeFilename = transliterator_transliterate(
'Any-Latin; Latin-ASCII; [^A-Za-z0-9_] remove; Lower()',
$originalFilename
);
$originalName = $uploadedFile->getClientOriginalName();
$safeFilename = null;
$extension = null;
$success = true;
$extension = $uploadedFile->guessExtension();
$allowed = InvoiceDocumentUploadForm::EXTENSIONS_NO_TWIG;
if ((bool) $systemConfiguration->find('invoice.upload_twig') === true) {
$allowed = InvoiceDocumentUploadForm::EXTENSIONS;
}
$newFilename = substr($safeFilename, 0, 20) . '.' . $extension;
foreach ($allowed as $ext) {
$len = \strlen($ext);
if (substr_compare($originalName, $ext, -$len) === 0) {
$extension = $ext;
$withoutExtension = str_replace($ext, '', $originalName);
$safeFilename = transliterator_transliterate(InvoiceDocumentUploadForm::FILENAME_RULE, $withoutExtension);
break;
}
}
try {
$uploadedFile->move($invoiceDir, $newFilename);
if ($safeFilename === null || $extension === null) {
$success = false;
$this->flashError('Invalid file given');
} else {
$newFilename = substr($safeFilename, 0, 20) . $extension;
try {
$uploadedFile->move($invoiceDir, $newFilename);
// if this is a twig file, we directly try to compile the template
if (stripos($newFilename, '.twig') !== false) {
try {
$twig->enableAutoReload();
$twig->load('@invoice/' . $newFilename);
$twig->disableAutoReload();
} catch (Exception $ex) {
unlink($invoiceDir . $newFilename);
$success = false;
$this->flashException($ex, 'File was deleted, as Twig template is broken: ' . $ex->getMessage());
}
}
} catch (Exception $ex) {
$this->flashException($ex, 'action.upload.error');
}
}
if ($success) {
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('admin_invoice_document_upload');
} catch (Exception $ex) {
$this->flashException($ex, 'action.upload.error');
}
}
}

View File

@@ -495,6 +495,7 @@ final class SystemConfigurationController extends AbstractController
->setLabel('invoice.number_format')
->setRequired(true)
->setType(TextType::class)
->setConstraints([new NotBlank()])
->setTranslationDomain('system-configuration'),
(new Configuration())
->setName('invoice.simple_form')

View File

@@ -148,14 +148,13 @@ abstract class TimesheetAbstractController extends AbstractController
protected function create(Request $request, string $renderTemplate): Response
{
$entry = $this->service->createNewTimesheet($this->getUser());
$entry = $this->service->createNewTimesheet($this->getUser(), $request);
$preForm = $this->createFormForGetRequest(TimesheetPreCreateForm::class, $entry, [
'include_user' => $this->includeUserInForms('create'),
]);
$preForm->submit($request->query->all(), false);
$this->service->prepareNewTimesheet($entry, $request);
$createForm = $this->getCreateForm($entry);
$createForm->handleRequest($request);

View File

@@ -95,14 +95,12 @@ class TeamFixtures extends Fixture
$team->setName($faker->company . ' ' . $i);
$team->addTeamlead($allUsers[array_rand($allUsers)]);
if ($userCount > 0) {
$userKeys = array_rand($allUsers, $userCount);
if (!\is_array($userKeys)) {
$userKeys = [$userKeys];
}
foreach ($userKeys as $userKey) {
$team->addUser($allUsers[$userKey]);
}
$userKeys = array_rand($allUsers, $userCount);
if (!\is_array($userKeys)) {
$userKeys = [$userKeys];
}
foreach ($userKeys as $userKey) {
$team->addUser($allUsers[$userKey]);
}
if ($projectCount > 0) {

View File

@@ -333,6 +333,9 @@ class Configuration implements ConfigurationInterface
->scalarNode('number_format')
->defaultValue('{Y}/{cy,3}')
->end()
->booleanNode('upload_twig')
->defaultTrue()
->end()
->end()
;

View File

@@ -437,12 +437,21 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
{
// only auto calculate if manually set duration is null - the result is important for eg. validations
if ($calculate && $this->duration === null && $this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
return $this->getCalculatedDuration();
}
return $this->duration;
}
public function getCalculatedDuration(): ?int
{
if ($this->begin !== null && $this->end !== null) {
return $this->end->getTimestamp() - $this->begin->getTimestamp();
}
return null;
}
/**
* @param User $user
* @return Timesheet

View File

@@ -45,6 +45,16 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer
throw new \Exception('Could not open temporary file');
}
$this->applyStyles($spreadsheet);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
protected function applyStyles(Spreadsheet $spreadsheet): void
{
// Store expensive calculations for later
$sheet = $spreadsheet->getActiveSheet();
$highestRow = $sheet->getHighestRow();
@@ -75,11 +85,6 @@ class XlsxRenderer extends AbstractSpreadsheetRenderer
->getAlignment()
->setVertical(Alignment::VERTICAL_TOP)
->setHorizontal(Alignment::HORIZONTAL_LEFT);
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
public function getId(): string

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Configuration\SystemConfiguration;
use App\Repository\InvoiceDocumentRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
@@ -21,11 +22,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
class InvoiceDocumentUploadForm extends AbstractType
{
private $repository;
public const EXTENSIONS = ['.html.twig', '.pdf.twig', '.docx', '.xlsx', '.ods'];
public const EXTENSIONS_NO_TWIG = ['.docx', '.xlsx', '.ods'];
public const FILENAME_RULE = 'Any-Latin; Latin-ASCII; [^A-Za-z0-9_\-] remove; Lower()';
public function __construct(InvoiceDocumentRepository $repository)
private $repository;
private $systemConfiguration;
private $extensions = [];
public function __construct(InvoiceDocumentRepository $repository, SystemConfiguration $systemConfiguration)
{
$this->repository = $repository;
$this->systemConfiguration = $systemConfiguration;
}
/**
@@ -33,22 +41,32 @@ class InvoiceDocumentUploadForm extends AbstractType
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$this->extensions = self::EXTENSIONS_NO_TWIG;
$extensions = 'DOCX, ODS, XLSX';
$mimetypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.oasis.opendocument.spreadsheet',
];
if ((bool) $this->systemConfiguration->find('invoice.upload_twig') === true) {
$this->extensions = self::EXTENSIONS;
$extensions = 'DOCX, ODS, XLSX, TWIG (PDF & HTML)';
$mimetypes = array_merge($mimetypes, [
'application/octet-stream', // needed for twig templates
'text/html', // needed for twig templates
'text/plain', // needed for twig templates
]);
}
$builder
->add('document', FileType::class, [
'label' => 'label.invoice_renderer',
'translation_domain' => 'invoice-renderer',
'help' => 'help.upload',
'help_translation_parameters' => ['%extensions%' => $extensions],
'mapped' => false,
'required' => true,
'attr' => [
'accept' => implode(',', $mimetypes)
],
'constraints' => [
new File([
'mimeTypes' => $mimetypes,
@@ -77,6 +95,48 @@ class InvoiceDocumentUploadForm extends AbstractType
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-01')
->addViolation();
return;
}
$extension = null;
$nameWithoutExtension = null;
foreach ($this->extensions as $ext) {
$len = \strlen($ext);
if (substr_compare($name, $ext, -$len) === 0) {
$extension = $ext;
$nameWithoutExtension = str_replace($ext, '', $name);
break;
}
}
if ($extension === null) {
$context->buildViolation('This invoice document cannot be used, allowed file extensions are: %extensions%')
->setParameters(['%extensions%' => implode(', ', $this->extensions)])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-02')
->addViolation();
return;
}
$safeFilename = transliterator_transliterate(self::FILENAME_RULE, $nameWithoutExtension);
if ($safeFilename !== $nameWithoutExtension) {
$context->buildViolation('This invoice document cannot be used, filename may only contain the following ascii character: %character%')
->setParameters(['%character%' => 'A-Z a-z 0-9 _ -'])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-03')
->addViolation();
}
if (mb_strlen($nameWithoutExtension) > 20) {
$context->buildViolation('This invoice document cannot be used, allowed filename length without extension is %character% character.')
->setParameters(['%character%' => 20])
->setTranslationDomain('validators')
->setCode('kimai-invoice-document-upload-04')
->addViolation();
}
}

View File

@@ -10,6 +10,7 @@
namespace App\Form;
use App\Form\Type\DescriptionType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\TagsInputType;
use App\Form\Type\UserType;
use Symfony\Component\Form\AbstractType;
@@ -29,6 +30,7 @@ class TimesheetPreCreateForm extends AbstractType
$this->addActivity($builder, null, null, ['required' => false]);
$builder->add('description', DescriptionType::class, ['required' => false]);
$builder->add('tags', TagsInputType::class, ['required' => false]);
$builder->add('metaFields', MetaFieldsCollectionType::class);
if ($options['include_user']) {
$builder->add('user', UserType::class, ['required' => false]);
}

View File

@@ -217,11 +217,17 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
break;
case 'cname':
$partialResult = $this->model->getCustomer() !== null ? $this->model->getCustomer()->getName() : '';
$partialResult = $this->model->getCustomer() !== null ? $this->model->getCustomer()->getName() : null;
if ($partialResult === null) {
throw new \InvalidArgumentException('Customer has no name, replacer {cname} failed evaluation.');
}
break;
case 'cnumber':
$partialResult = $this->model->getCustomer() !== null ? $this->model->getCustomer()->getNumber() : '';
$partialResult = $this->model->getCustomer() !== null ? $this->model->getCustomer()->getNumber() : null;
if ($partialResult === null) {
throw new \InvalidArgumentException('Customer has no number, replacer {cnumber} failed evaluation.');
}
break;
default:

View File

@@ -15,6 +15,7 @@ use App\Export\Base\DispositionInlineTrait;
use App\Export\ExportContext;
use App\Invoice\InvoiceFilename;
use App\Invoice\InvoiceModel;
use App\Utils\FileHelper;
use App\Utils\HtmlToPdfConverter;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;
@@ -61,6 +62,8 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
$response = new Response($content);
$filename = FileHelper::convertToAsciiFilename($filename);
$disposition = $response->headers->makeDisposition($this->getDisposition(), $filename . '.pdf');
$response->headers->set('Content-Type', 'application/pdf');

View File

@@ -161,7 +161,7 @@ final class InvoiceDocumentRepository
continue;
}
$finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->name('*.*');
$finder = Finder::create()->ignoreDotFiles(true)->files()->in($searchDir)->depth(0)->name('*.*');
foreach ($finder->getIterator() as $file) {
$doc = new InvoiceDocument($file);
// the first found invoice document wins

View File

@@ -81,8 +81,20 @@ final class TimesheetBudgetUsedValidator extends ConstraintValidator
// when changing the date via the calendar and/or the API, the duration will not be reset by the
// duration calculator (which runs after validation!) so we manually reset the duration before
$timesheet->setDuration(null);
$duration = $timesheet->getDuration();
// ------------------------------------------------------------------------
// Old solution (buggy) - duration MAY NOT BE RESET!
// this will cause the timesheet to be deleted in the Weekly-QuickEntry-Flow
// ------------------------------------------------------------------------
// $timesheet->setDuration(null);
// $duration = $timesheet->getDuration();
// another possible solution is cloning the timesheet
// $timesheet = clone $timesheet;
// $timesheet->setDuration(null);
// $duration = $timesheet->getDuration();
$duration = $timesheet->getCalculatedDuration();
$timeRate = $this->rateService->calculate($timesheet);
$rate = $timeRate->getRate();

View File

@@ -149,7 +149,7 @@
{% elseif config.entryTitlePattern == constant('\\App\\Form\\Type\\CalendarTitlePatternType::PATTERN_CUSTOMER_DESCRIPTION') %}
title = apiItem.project.customer.name + description;
{% elseif config.entryTitlePattern == constant('\\App\\Form\\Type\\CalendarTitlePatternType::PATTERN_PROJECT_CUSTOMER') %}
title = apiItem.project.name + apiItem.project.customer.name;
title = apiItem.project.name + '{{ constant('\\App\\Form\\Type\\CalendarTitlePatternType::SPACER') }}' + apiItem.project.customer.name;
{% endif %}
if (title === '' || title === null) {
@@ -180,6 +180,13 @@
{
const escaper = kimai.getPlugin('escape');
let tags = '';
if (eventObj.tags !== null && eventObj.tags.length > 0) {
for (let tag of eventObj.tags) {
tags += '<span class="badge bg-green">' + escaper.escapeForHtml(tag) + '</span>';
}
}
return '<div class="calendar-entry">' +
'<ul>' +
'<li>' + '{{ 'label.customer'|trans }}: ' + escaper.escapeForHtml(eventObj.customer) + '</li>' +
@@ -187,8 +194,7 @@
'<li>' + '{{ 'label.activity'|trans }}: ' + escaper.escapeForHtml(eventObj.activity) + '</li>' +
'</ul>' +
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
(eventObj.description ? '<p>' + eventObj.description + '</p>' : '') +
(eventObj.tags !== null && eventObj.tags.length > 0 ? '<span class="badge bg-green">' + eventObj.tags.join('</span> <span class="badge bg-green">') + '</span>' : '') +
(eventObj.description ? '<p>' + escaper.escapeForHtml(eventObj.description) + '</p>' : '') + tags +
'</div>'
;
}

View File

@@ -773,6 +773,36 @@ class TimesheetControllerTest extends APIControllerBaseTest
self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]);
}
public function testGetRecentActionForUser(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$start = new \DateTime('-10 days');
$user = $this->getUserByRole(User::ROLE_ADMIN);
$fixture = new TimesheetFixtures();
$fixture->setFixedRate(true);
$fixture->setHourlyRate(true);
$fixture->setAmount(10);
$fixture->setUser($user);
$fixture->setStartDate($start);
$this->importFixture($fixture);
$query = [
'user' => $user->getId(),
'size' => 2,
'begin' => $start->format(self::DATE_FORMAT_HTML5),
];
$this->assertAccessIsGranted($client, '/api/timesheets/recent', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result);
$this->assertNotEmpty($result);
$this->assertEquals(1, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]);
}
public function testActiveAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);

View File

@@ -325,6 +325,7 @@ class ConfigurationTest extends TestCase
],
'simple_form' => false,
'number_format' => '{Y}/{cy,3}',
'upload_twig' => true,
],
'export' => [
'documents' => [

View File

@@ -31,7 +31,6 @@ class TimesheetExportRepositoryTest extends TestCase
$sut = new TimesheetExportRepository($repository);
/* @phpstan-ignore-next-line */
$sut->setExported([new Timesheet(), null, new \stdClass(), new Timesheet(), new Activity()]);
// test else for empty array
/* @phpstan-ignore-next-line */

View File

@@ -166,6 +166,38 @@ class ConfigurableNumberGeneratorTest extends TestCase
$this->assertEquals('default', $sut->getId());
}
public function getMissingFieldTestData()
{
return [
['{Y}/{cnumber}_{ccy,3}', 'Customer has no number, replacer {cnumber} failed evaluation.'],
['{Y}/{cname}_{ccy,3}', 'Customer has no name, replacer {cname} failed evaluation.'],
];
}
/**
* @dataProvider getMissingFieldTestData
*/
public function testCustomerHasMissingField(string $format, string $message)
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage($message);
$user = $this->createMock(User::class);
$user->method('getId')->willReturn(13);
$user->method('getAccountNumber')->willReturn('0815');
$customer = new Customer();
$sut = $this->getSut($format);
$model = (new InvoiceModelFactoryFactory($this))->create()->createModel(new DebugFormatter());
$model->setInvoiceDate(new \DateTime());
$model->setCustomer($customer);
$model->setUser($user);
$sut->setModel($model);
$sut->getInvoiceNumber();
}
public function getInvalidTestData()
{
$invoiceDate = new \DateTime();

View File

@@ -31,7 +31,6 @@ class TimesheetInvoiceItemRepositoryTest extends TestCase
$sut = new TimesheetInvoiceItemRepository($repository);
/* @phpstan-ignore-next-line */
$sut->setExported([new Timesheet(), null, new \stdClass(), new Timesheet(), new Activity()]);
// test else for empty array
/* @phpstan-ignore-next-line */

View File

@@ -412,6 +412,7 @@ class TimesheetBudgetUsedValidatorTest extends ConstraintValidatorTestCase
$timesheet->method('getBegin')->willReturn($begin);
$timesheet->method('getEnd')->willReturn($end);
$timesheet->method('getDuration')->willReturn($end->getTimestamp() - $begin->getTimestamp());
$timesheet->method('getCalculatedDuration')->willReturn($end->getTimestamp() - $begin->getTimestamp());
$timesheet->method('getUser')->willReturn(new User());
$timesheet->method('getProject')->willReturn($project);
$timesheet->method('getActivity')->willReturn($activity);

View File

@@ -60,7 +60,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">انتباه: سيتم الكتابة فوق الملفات الموجودة. أنواع الملفات المسموح بها هي: DOCX و ODS و XLSX.</target>
<target state="translated">انتباه: سيتم الكتابة فوق الملفات الموجودة. أنواع الملفات المسموح بها هي: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Pozor: existující soubory budou přepsány. Povolené typy jsou: DOCX, ODS, XLSX.</target>
<target state="translated">Pozor: existující soubory budou přepsány. Povolené typy jsou: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX.</target>
<target state="translated">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="needs-translation">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: DOCX, ODS, XLSX</target>
<target state="needs-translation">Achtung: existierende Dateien werden überschrieben. Erlaubte Dateitypen sind: %extensions%</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Προσοχή: τα υπάρχοντα αρχεία θα αντικατασταθούν. Επιτρεπόμενοι τύποι αρχείων είναι: DOCX, ODS, XLSX.</target>
<target state="translated">Προσοχή: τα υπάρχοντα αρχεία θα αντικατασταθούν. Επιτρεπόμενοι τύποι αρχείων είναι: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
<target>Attention: existing files will be overwritten. Allowed file types are: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atentu: ekzistantaj dosieroj estos anstataŭigitaj. Permesitaj tipoj de dosieroj estas: DOCX, ODS, XLSX.</target>
<target>Atentu: ekzistantaj dosieroj estos anstataŭigitaj. Permesitaj tipoj de dosieroj estas: %extensions%.</target>
</trans-unit>
</body>
</file>

View File

@@ -44,7 +44,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Atención: Los archivos existentes serán sobrescritos. Los tipos de archivo permitidos son: DOCX, ODS, XLSX.</target>
<target state="translated">Atención: Los archivos existentes serán sobrescritos. Los tipos de archivo permitidos son: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">توجه: فایل های موجود رونویسی خواهند شد. فایل های مجاز عبارتند از: DOCX، ODS، XLSX.</target>
<target state="translated">توجه: فایل های موجود رونویسی خواهند شد. فایل های مجاز عبارتند از: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Huomioitavaa: olemassa olevat tiedostot korvataan. Sallitut tiedosto tyypit on: DOCX, ODS, XLSX.</target>
<target state="translated">Huomioitavaa: olemassa olevat tiedostot korvataan. Sallitut tiedosto tyypit on: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="needs-translation">Attention: existing files will be overwritten. Allowed file types are: DOCX, ODS, XLSX.</target>
<target state="needs-translation">Attention: existing files will be overwritten. Allowed file types are: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -48,7 +48,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attention : les fichiers existants seront écrasés. Les types de fichiers autorisés sont : DOCX, ODS, XLSX.</target>
<target>Attention : les fichiers existants seront écrasés. Les types de fichiers autorisés sont : %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>שים לב: קבצים קיימים ידרסו וימחקו. סוגי קבצים מותרים: DOCX, ODS, XLSX.</target>
<target>שים לב: קבצים קיימים ידרסו וימחקו. סוגי קבצים מותרים: %extensions%.</target>
</trans-unit>
<trans-unit id="sjwwrr8" resname="Xlsx">
<source>Microsoft Excel (XLSX)</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Oprez: postojeće datoteke će se prepisati. Dopuštene vrste datoteka su: DOCX, ODS, XLSX.</target>
<target>Oprez: postojeće datoteke će se prepisati. Dopuštene vrste datoteka su: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -40,7 +40,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Figyelem: a meglévő fájlok felülíródnak. A megengedett fájltípusok: DOCX, ODS, XLSX.</target>
<target>Figyelem: a meglévő fájlok felülíródnak. A megengedett fájltípusok: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -44,7 +44,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>주의: 기존 파일을 덮어씁니다. 허용되는 파일 형식은 DOCX, ODS, XLSX입니다.</target>
<target>주의: 기존 파일을 덮어씁니다. 허용되는 파일 형식은 %extensions%입니다.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Obs: Eksisterende filer vil bli overskrevet. Filtypene DOCX, ODS og XLSX støttes.</target>
<target>Obs: Eksisterende filer vil bli overskrevet. Filtypene %extensions% støttes.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Attentie: Bestaande bestanden zullen overschreven worden. Toegestaande bestandstypes zijn: DOCX, ODS, XLSX.</target>
<target>Attentie: Bestaande bestanden zullen overschreven worden. Toegestaande bestandstypes zijn: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -44,7 +44,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Uwaga: istniejące pliki zostaną nadpisane. Dozwolone typu plików to: DOCX, ODS, XLSX.</target>
<target state="translated">Uwaga: istniejące pliki zostaną nadpisane. Dozwolone typu plików to: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Atenção: os ficheiros já existentes serão substituídos. Os tipos de ficheiros permitidos são: DOCX, ODS, XLSX.</target>
<target state="translated">Atenção: os ficheiros já existentes serão substituídos. Os tipos de ficheiros permitidos são: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atenção: os arquivos já existentes serão substituídos. Os tipos dos arquivos permitidos são: DOCX, ODS, XLSX.</target>
<target>Atenção: os arquivos já existentes serão substituídos. Os tipos dos arquivos permitidos são: %extensions%.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Atenție: fișierele existente vor fi suprascrise. Tipuri de fișiere permise: DOCX, ODS, XLSX.</target>
<target>Atenție: fișierele existente vor fi suprascrise. Tipuri de fișiere permise: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -20,7 +20,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Внимание: существующие файлы будут перезаписаны. Допустимые типы файлов: DOCX, ODS, XLSX.</target>
<target state="translated">Внимание: существующие файлы будут перезаписаны. Допустимые типы файлов: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Obs: redan exsiterande filer kommer att skrivas över. Tillåtna fil typer är: DOCX, ODS, XLSX.</target>
<target state="translated">Obs: redan exsiterande filer kommer att skrivas över. Tillåtna fil typer är: %extensions%.</target>
</trans-unit>
<trans-unit id="N6juwc4" resname="default">
<source>default</source>

View File

@@ -60,7 +60,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Dikkat: mevcut dosyaların üzerine yazılacaktır. İzin verilen dosya türleri: DOCX, ODS, XLSX.</target>
<target>Dikkat: mevcut dosyaların üzerine yazılacaktır. İzin verilen dosya türleri: %extensions%.</target>
</trans-unit>
<trans-unit id="nA.KOyM" resname="Html">
<source>Html</source>

View File

@@ -8,7 +8,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">Увага: існуючі файли будуть перезаписані. Дозволені типи файлів: DOCX, ODS, XLSX.</target>
<target state="translated">Увага: існуючі файли будуть перезаписані. Дозволені типи файлів: %extensions%.</target>
</trans-unit>
<!-- Optgroups -->
<trans-unit id="E5BwZHT" resname="programmatic">

View File

@@ -36,7 +36,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target>Chú ý: các tệp hiện có sẽ bị ghi đè. Các loại tệp được phép là: DOCX, ODS, XLSX.</target>
<target>Chú ý: các tệp hiện có sẽ bị ghi đè. Các loại tệp được phép là: %extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

View File

@@ -60,7 +60,7 @@
</trans-unit>
<trans-unit id="7mEUv6C" resname="help.upload">
<source>help.upload</source>
<target state="translated">注意:现有文件将被覆盖。允许的文件类型是:DOCX, ODS, XLSX.</target>
<target state="translated">注意:现有文件将被覆盖。允许的文件类型是:%extensions%.</target>
</trans-unit>
<trans-unit id="E5BwZHT" resname="programmatic">
<source>programmatic</source>

576
yarn.lock

File diff suppressed because it is too large Load Diff