Release 2.32 (#5411)

* bump packages
* dynamic invoice options
* make sure that invoice previews can be detected
* support for mpdf associated files
* do not include any future times in work contract calculation
* re-add username column in Excel spreadsheet
* deactivate internal rate editing
* show if plugin update exists
* shorten name to Kimai only, without Time-Tracking
* remove check for existing id in work contract
* fix metafield already defined in search
* helper methods to unlock months
* new translation
* send event on unlock month
This commit is contained in:
Kevin Papst
2025-04-06 09:53:48 +02:00
committed by GitHub
parent 2a75cd6230
commit 2e6b700b43
58 changed files with 749 additions and 437 deletions

View File

@@ -30,6 +30,7 @@
"doctrine/doctrine-bundle": "^2.7",
"doctrine/doctrine-migrations-bundle": "^3.3",
"doctrine/orm": "^2.8",
"easybill/zugferd-php": "^2.1",
"endroid/qr-code": "^4.8",
"erusev/parsedown": "^1.6",
"friendsofsymfony/rest-bundle": "^3.0",

630
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3432,26 +3432,6 @@ parameters:
count: 1
path: src/Model/Statistic/Month.php
-
message: "#^Method App\\\\Pdf\\\\HtmlToPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Pdf/HtmlToPdfConverter.php
-
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:convertToPdf\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Pdf/MPdfConverter.php
-
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
count: 1
path: src/Pdf/MPdfConverter.php
-
message: "#^Method App\\\\Pdf\\\\MPdfConverter\\:\\:sanitizeOptions\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Pdf/MPdfConverter.php
-
message: "#^Method App\\\\Pdf\\\\PdfContext\\:\\:getOption\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -1,5 +1,5 @@
{
"name": "Kimai Time-Tracker",
"name": "Kimai",
"short_name": "Kimai",
"icons": [
{

View File

@@ -9,18 +9,13 @@
namespace App\API\Authentication;
use App\Entity\User;
use Symfony\Component\Security\Core\Exception\LogicException;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\BadgeInterface;
final class ApiTokenUpgradeBadge implements BadgeInterface
{
/**
* @param string|null $plaintextApiToken
* @param PasswordUpgraderInterface<User> $passwordUpgrader
*/
public function __construct(private ?string $plaintextApiToken, private PasswordUpgraderInterface $passwordUpgrader)
public function __construct(private ?string $plaintextApiToken, private readonly PasswordUpgraderInterface $passwordUpgrader)
{
}
@@ -36,9 +31,6 @@ final class ApiTokenUpgradeBadge implements BadgeInterface
return $password;
}
/**
* @return PasswordUpgraderInterface<User>
*/
public function getPasswordUpgrader(): PasswordUpgraderInterface
{
return $this->passwordUpgrader;

View File

@@ -454,10 +454,12 @@ final class TranslationCommand extends Command
foreach ($xml->file->body->{'trans-unit'} as $unit) {
$source = $unit->source;
if (!isset($unit['resname'])) {
if (!isset($unit['resname']) && $source !== null) {
$unit['resname'] = $source;
}
$unit['id'] = $this->generateId($unit['resname']);
if ($unit['resname'] !== null) {
$unit['id'] = $this->generateId($unit['resname']); // @phpstan-ignore offsetAssign.valueType
}
}
$xmlDocument = new \DOMDocument('1.0');
@@ -491,7 +493,7 @@ final class TranslationCommand extends Command
);
}
$unit->target[0] = $translations[$key];
$unit->target['state'] = 'needs-translation';
$unit->target['state'] = 'needs-translation'; // @phpstan-ignore assign.propertyType
$foundEmpty = true;
}

View File

@@ -17,11 +17,11 @@ final class Constants
/**
* The current release version
*/
public const VERSION = '2.31.0';
public const VERSION = '2.32.0';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 23100;
public const VERSION_ID = 23200;
/**
* The software name
*/

View File

@@ -164,6 +164,7 @@ final class InvoiceController extends AbstractController
try {
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
$model->setPreview(true);
return $this->service->renderInvoice($model, $this->dispatcher, true);
} catch (Exception $ex) {

View File

@@ -34,11 +34,20 @@ final class PluginController extends AbstractController
$page = new PageSetup('menu.plugin');
$page->setHelp('plugins.html');
$all = $this->getPluginInformation($client, $cache);
$bundles = [];
foreach ($all as $item) {
if ($item['bundle'] !== null) {
$bundles[$item['bundle']] = $item;
}
}
return $this->render('plugin/index.html.twig', [
'page_setup' => $page,
'plugins' => $plugins,
'installed' => $installed,
'extensions' => $this->getPluginInformation($client, $cache)
'extensions' => $all,
'bundles' => $bundles,
]);
}

View File

@@ -15,7 +15,7 @@ use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceCreatedEvent extends Event
{
public function __construct(private Invoice $invoice, private InvoiceModel $model)
public function __construct(private readonly Invoice $invoice, private readonly InvoiceModel $model)
{
}

View File

@@ -47,6 +47,9 @@ final class InvoiceDocumentsEvent extends Event
$this->documents = $documents;
}
/**
* @CloudRequired
*/
public function setMaximumAllowedDocuments(int $max): void
{
$this->maximum = $max;

View File

@@ -16,7 +16,11 @@ use Symfony\Contracts\EventDispatcher\Event;
final class InvoicePreRenderEvent extends Event
{
public function __construct(private InvoiceModel $model, private InvoiceDocument $document, private RendererInterface $renderer)
public function __construct(
private readonly InvoiceModel $model,
private readonly InvoiceDocument $document,
private readonly RendererInterface $renderer
)
{
}

View File

@@ -11,6 +11,9 @@ namespace App\Event;
use Symfony\Contracts\EventDispatcher\Event;
/**
* @deprecated since 2.32 - use TranslatorInterface directly
*/
final class ThemeJavascriptTranslationsEvent extends Event
{
/**

View File

@@ -15,13 +15,19 @@ use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeApproveMonthEvent extends Event
{
public function __construct(private User $user, private Month $month, private \DateTimeInterface $approvalDate, private User $approver)
public function __construct(
private readonly Month $month,
private readonly User $approvedBy
)
{
}
/**
* @deprecated use getMonth()->getUser() instead)
*/
public function getUser(): User
{
return $this->user;
return $this->getMonth()->getUser();
}
public function getMonth(): Month
@@ -29,13 +35,8 @@ final class WorkingTimeApproveMonthEvent extends Event
return $this->month;
}
public function getApprovalDate(): \DateTimeInterface
public function getApprovedBy(): User
{
return $this->approvalDate;
}
public function getApprover(): User
{
return $this->approver;
return $this->approvedBy;
}
}

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\User;
use App\WorkingTime\Model\Month;
use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeUnlockMonthEvent extends Event
{
public function __construct(
private readonly Month $month,
private readonly User $unlockedBy
)
{
}
public function getMonth(): Month
{
return $this->month;
}
public function getUnlockedBy(): User
{
return $this->unlockedBy;
}
}

View File

@@ -164,7 +164,8 @@ final class SpreadsheetRenderer
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
}
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('alias', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('username', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getUserIdentifier())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('account_number', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
$columns[] = (new Column('customer', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);
$columns[] = (new Column('project', $this->getFormatter('default')))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName())->withColumnWidth(ColumnWidth::MEDIUM);

View File

@@ -360,16 +360,13 @@ class TimesheetEditForm extends AbstractType
return;
}
$moneyOptions = ['currency' => $currency];
$builder
->add('fixedRate', FixedRateType::class, [
'currency' => $currency,
])
->add('hourlyRate', HourlyRateType::class, [
'currency' => $currency,
])
->add('internalRate', InternalRateType::class, [
'currency' => $currency,
]);
->add('fixedRate', FixedRateType::class, $moneyOptions)
->add('hourlyRate', HourlyRateType::class, $moneyOptions)
//->add('internalRate', InternalRateType::class, $moneyOptions)
;
}
protected function addUser(FormBuilderInterface $builder, array $options): void

View File

@@ -52,6 +52,11 @@ final class InvoiceModel
private array $itemHydrator = [];
private ?string $invoiceNumber = null;
private bool $hideZeroTax = false;
private bool $isPreview = false;
/**
* @var array<string, string|array<string|int, mixed>|null|bool|int|float>
*/
private array $options = [];
/**
* @internal use InvoiceModelFactory
@@ -68,6 +73,22 @@ final class InvoiceModel
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
}
/**
* @param string|array<string|int, mixed>|null|bool|int|float $value
*/
public function setOption(string $key, string|array|null|bool|int|float $value): void
{
$this->options[$key] = $value;
}
/**
* @return array<string, string|array<string|int, mixed>|null|bool|int|float>
*/
public function getOptions(): array
{
return $this->options;
}
public function getQuery(): ?InvoiceQuery
{
return $this->query;
@@ -265,4 +286,14 @@ final class InvoiceModel
{
$this->hideZeroTax = $hideZeroTax;
}
public function isPreview(): bool
{
return $this->isPreview;
}
public function setPreview(bool $preview): void
{
$this->isPreview = $preview;
}
}

View File

@@ -23,7 +23,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
{
use PDFRendererTrait;
public function __construct(Environment $twig, private HtmlToPdfConverter $converter)
public function __construct(Environment $twig, private readonly HtmlToPdfConverter $converter)
{
parent::__construct($twig);
}
@@ -45,7 +45,7 @@ final class PdfRenderer extends AbstractTwigRenderer implements DispositionInlin
$context->setOption('margin_bottom', '8');
$content = $this->renderTwigTemplate($document, $model, ['pdfContext' => $context]);
$content = $this->converter->convertToPdf($content, $context->getOptions());
$content = $this->converter->convertToPdf($content, array_merge($model->getOptions(), $context->getOptions()));
return $this->createPdfResponse($content, $context);
}

View File

@@ -15,9 +15,7 @@ interface HtmlToPdfConverter
* Returns the binary content of the PDF, which can be saved as file.
* Throws an exception if conversion fails.
*
* @param string $html
* @param array $options
* @return string
* @param array<string, mixed|array<string, mixed>> $options
* @throws \Exception
*/
public function convertToPdf(string $html, array $options = []): string;

View File

@@ -18,22 +18,29 @@ use Mpdf\Output\Destination;
final class MPdfConverter implements HtmlToPdfConverter
{
public function __construct(private FileHelper $fileHelper, private string $cacheDirectory)
public function __construct(
private readonly FileHelper $fileHelper,
private readonly string $cacheDirectory
)
{
}
/**
* @param array<string, mixed|array<string, mixed>> $options
* @return array<string, mixed|array<string, mixed>>
*/
private function sanitizeOptions(array $options): array
{
$configs = new ConfigVariables();
$fonts = new FontVariables();
$filtered = array_filter($options, function ($key): bool {
$allowed = [
'mode', 'format', 'default_font_size', 'default_font', 'margin_left', 'margin_right', 'margin_top',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts',
'margin_bottom', 'margin_header', 'margin_footer', 'orientation', 'fonts', 'associated_files'
];
$filtered = array_filter($options, function ($key) use ($allowed, $configs, $fonts): bool {
if (!\in_array($key, $allowed)) {
$configs = new ConfigVariables();
if (!\array_key_exists($key, $configs->getDefaults())) {
$fonts = new FontVariables();
return \array_key_exists($key, $fonts->getDefaults());
}
}
@@ -49,10 +56,7 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param string $html
* @param array $options
* @return string
* @throws \Mpdf\MpdfException
* @param array<string, mixed|array<string, mixed>> $options
*/
public function convertToPdf(string $html, array $options = []): string
{
@@ -93,17 +97,26 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param array<string, array<mixed>> $options
* @return Mpdf
* @param array<string, mixed|array<string, mixed>> $options
*/
private function initMpdf(array $options): Mpdf
{
$options['fontDir'] = $this->getFontDirectories();
$options['fontdata'] = $this->mergeFontData($options);
$associatedFiles = [];
if (\array_key_exists('associated_files', $options) && \is_array($options['associated_files'])) {
$associatedFiles = $options['associated_files'];
unset($options['associated_files']);
}
$mpdf = new Mpdf($options);
$mpdf->creator = Constants::SOFTWARE;
if (\count($associatedFiles) > 0) {
$mpdf->SetAssociatedFiles($associatedFiles);
}
return $mpdf;
}
@@ -120,8 +133,8 @@ final class MPdfConverter implements HtmlToPdfConverter
}
/**
* @param array<string, array<mixed>> $options
* @return array<string, array<mixed>>
* @param array<string, mixed|array<string, mixed>> $options
* @return array<string, mixed|array<string, mixed>>
*/
private function mergeFontData(array $options): array
{

View File

@@ -15,12 +15,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
use Symfony\Component\Security\Core\User\UserInterface;
/**
* @template-implements PasswordUpgraderInterface<User>
*/
class ApiUserRepository implements UserLoaderInterface, PasswordUpgraderInterface
{
public function __construct(private UserRepository $userRepository)
public function __construct(private readonly UserRepository $userRepository)
{
}

View File

@@ -68,6 +68,7 @@ trait RepositorySearchTrait
$alias = 'meta' . $a++;
$paramName = 'metaName' . $i++;
$paramValue = 'metaValue' . $c++;
$subqueryName = 'metaNotExists' . $metaName;
if ($metaValue === '*') {
$qb->leftJoin($rootAlias . '.meta', $alias);
@@ -76,7 +77,7 @@ trait RepositorySearchTrait
$and->add($qb->expr()->isNotNull($alias . '.value'));
} elseif ($metaValue === '~') {
$and->add(
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
);
} elseif ($metaValue === '' || $metaValue === null) {
$qb->leftJoin($rootAlias . '.meta', $alias);
@@ -86,7 +87,7 @@ trait RepositorySearchTrait
$qb->expr()->eq($alias . '.name', ':' . $paramName),
$qb->expr()->isNull($alias . '.value')
),
\sprintf('NOT EXISTS(SELECT metaNotExists FROM %s metaNotExists WHERE metaNotExists.%s = %s.id)', $this->getMetaFieldClass(), $this->getMetaFieldName(), $rootAlias)
\sprintf('NOT EXISTS(SELECT %s FROM %s %s WHERE %s.%s = %s.id)', $subqueryName, $this->getMetaFieldClass(), $subqueryName, $subqueryName, $this->getMetaFieldName(), $rootAlias)
)
);
$qb->setParameter($paramName, $metaName);

View File

@@ -35,7 +35,6 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @extends EntityRepository<User>
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
class UserRepository extends EntityRepository implements UserLoaderInterface, UserProviderInterface, PasswordUpgraderInterface

View File

@@ -21,18 +21,10 @@ class WorkingTimeRepository extends EntityRepository
{
private bool $pendingUpdate = false;
public function deleteWorkingTime(WorkingTime $workingTime): void
public function scheduleWorkingTimeDelete(WorkingTime $workingTime): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($workingTime);
$entityManager->flush();
}
public function saveWorkingTime(WorkingTime $workingTime): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($workingTime);
$entityManager->flush();
$this->pendingUpdate = true;
$this->getEntityManager()->remove($workingTime);
}
public function scheduleWorkingTimeUpdate(WorkingTime $workingTime): void

View File

@@ -19,7 +19,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
final class KimaiUserProvider implements UserProviderInterface, PasswordUpgraderInterface
@@ -29,7 +28,7 @@ final class KimaiUserProvider implements UserProviderInterface, PasswordUpgrader
/**
* @param iterable<UserProviderInterface<User>> $providers
*/
public function __construct(private iterable $providers, private SystemConfiguration $configuration)
public function __construct(private readonly iterable $providers, private readonly SystemConfiguration $configuration)
{
}

View File

@@ -166,12 +166,7 @@ final class DateTimeFactory
return $date->modify('23:59:59');
}
/**
* @param string $format
* @param null|string $datetime
* @return bool|DateTime
*/
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): bool|DateTime
public function createDateTimeFromFormat(string $format, ?string $datetime = 'now'): false|DateTime
{
return DateTime::createFromFormat($format, $datetime ?? 'now', $this->getTimezone());
}

View File

@@ -24,15 +24,17 @@ use Twig\Extension\RuntimeExtensionInterface;
final class ThemeExtension implements RuntimeExtensionInterface
{
public function __construct(private EventDispatcherInterface $eventDispatcher, private TranslatorInterface $translator, private SystemConfiguration $configuration, private Security $security)
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly TranslatorInterface $translator,
private readonly SystemConfiguration $configuration,
private readonly Security $security
)
{
}
/**
* @param Environment $environment
* @param string $eventName
* @param array<string, mixed> $payload
* @return ThemeEvent
*/
public function trigger(Environment $environment, string $eventName, array $payload = []): ThemeEvent
{
@@ -61,7 +63,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function getJavascriptTranslations(): array
{
$event = new ThemeJavascriptTranslationsEvent();
$event = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
$this->eventDispatcher->dispatch($event);
@@ -97,7 +99,7 @@ final class ThemeExtension implements RuntimeExtensionInterface
public function generateTitle(?string $prefix = null, string $delimiter = ' '): string
{
return ($prefix ?? '') . Constants::SOFTWARE . $delimiter . $this->translator->trans('time_tracking', [], 'messages');
return ($prefix ?? '') . Constants::SOFTWARE;
}
public function colorize(?string $color, ?string $identifier = null): string

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Entity\WorkingTime;
use App\Event\WorkingTimeApproveMonthEvent;
use App\Event\WorkingTimeQueryStatsEvent;
use App\Event\WorkingTimeUnlockMonthEvent;
use App\Event\WorkingTimeYearEvent;
use App\Event\WorkingTimeYearSummaryEvent;
use App\Repository\TimesheetRepository;
@@ -155,6 +156,7 @@ final class WorkingTimeService
$dayDate = $day->getDay();
$result = new WorkingTime($user, $dayDate);
if ($dayDate <= $until) {
if (($firstDay === null || $firstDay <= $dayDate) && ($lastDay === null || $lastDay >= $dayDate)) {
$result->setExpectedTime($calculator->getWorkHoursForDay($dayDate));
}
@@ -162,6 +164,7 @@ final class WorkingTimeService
if (\array_key_exists($key, $stats)) {
$result->setActualTime($stats[$key]);
}
}
$day->setWorkingTime($result);
}
@@ -181,6 +184,7 @@ final class WorkingTimeService
return $year->getMonth($monthDate);
}
// deprecated 3.0 remove $user, fetch from $month->getUser() instead
public function approveMonth(User $user, Month $month, \DateTimeInterface $approvalDate, User $approvedBy): void
{
foreach ($month->getDays() as $day) {
@@ -189,10 +193,6 @@ final class WorkingTimeService
continue;
}
if ($workingTime->getId() !== null) {
continue;
}
if ($month->isLocked() || $workingTime->isApproved()) {
continue;
}
@@ -205,15 +205,38 @@ final class WorkingTimeService
$this->workingTimeRepository->persistScheduledWorkingTimes();
// $user = $month->getUser();
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
$this->userRepository->saveUser($user);
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($user, $month, $approvalDate, $approvedBy));
$this->eventDispatcher->dispatch(new WorkingTimeApproveMonthEvent($month, $approvedBy));
}
public function unlockMonth(Month $month, User $unlockedBy): void
{
foreach ($month->getDays() as $day) {
$workingTime = $day->getWorkingTime();
if ($workingTime === null || $workingTime->getId() === null) {
continue;
}
if (!$workingTime->isApproved()) {
continue;
}
$this->workingTimeRepository->scheduleWorkingTimeDelete($workingTime);
}
$this->workingTimeRepository->persistScheduledWorkingTimes();
$user = $month->getUser();
$user->setPreferenceValue(self::LATEST_APPROVAL_PREF, $this->workingTimeRepository->getLatestApprovalDate($user)?->format(self::LATEST_APPROVAL_FORMAT));
$this->userRepository->saveUser($user);
$this->eventDispatcher->dispatch(new WorkingTimeUnlockMonthEvent($month, $unlockedBy));
}
/**
* @param \DateTimeInterface $year
* @param User $user
* @return array<string, int>
*/
private function getYearStatistics(\DateTimeInterface $year, User $user): array

View File

@@ -98,10 +98,7 @@
{% for month in year.months %}
{% set monthShould = month.expectedTime(now) %}
{% set monthIs = month.actualTime %}
{% set showStats = now > month.month or monthIs > 0 %}
{% if showStats %}
{% set yearShould = yearShould + monthShould %}
{% endif %}
{% set yearIs = yearIs + monthIs %}
{% if month.locked %}
{% set approved = approved + 1 %}
@@ -137,7 +134,6 @@
{% for month in year.months %}
{% set monthShould = month.expectedTime(now) %}
{% set monthIs = month.actualTime %}
{% set showStats = now > month.month or monthIs > 0 %}
<tr>
{% if withWorkHourConfiguration %}
<td class="w-min">
@@ -152,9 +148,7 @@
{{ month.month|month_name }}
</td>
<td class="text-end total w-min">
{% if showStats %}
{{ work_times_result(monthShould, monthIs, decimal) }}
{% endif %}
</td>
<td class="text-end w-min">
{{ monthShould|duration(decimal) }}

View File

@@ -8,5 +8,5 @@
<img alt="{{ get_title() }}" style="max-width: 150px;"
src="{% if '://' in tabler_bundle.getLogoUrl() %}{{ tabler_bundle.getLogoUrl() }}{% else %}{{ asset(tabler_bundle.getLogoUrl()) }}{% endif %}">
{% else %}
<p>{{ get_title('', '<br>')|raw }}</p>
<p>{{ get_title()|raw }}</p>
{% endif %}

View File

@@ -29,6 +29,9 @@
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
{{ plugin.metadata.description }}
{% if bundles[plugin.id] is defined and bundles[plugin.id]['latest_release'] is defined and bundles[plugin.id]['latest_release'] != plugin.metadata.version %}
{{ widgets.alert('warning', 'update_available'|trans({'%version%': bundles[plugin.id]['latest_release']}, 'plugins')) }}
{% endif %}
</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'version') }}">
{{ widgets.label(plugin.metadata.version, 'primary', plugin.id) }}

View File

@@ -51,7 +51,7 @@ class PasswordResetControllerTest extends AbstractControllerBaseTestCase
$content = $response->getContent();
self::assertNotFalse($content);
self::assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
self::assertStringContainsString('<title>Kimai</title>', $content);
self::assertStringContainsString('Reset your password', $content);
self::assertStringContainsString('<form class="card-body security-password-reset" action="/en/resetting/send-email" method="post" autocomplete="off">', $content);
self::assertStringContainsString('<input autocomplete="username" type="text"', $content);

View File

@@ -45,7 +45,7 @@ class SecurityControllerTest extends AbstractControllerBaseTestCase
self::assertTrue($client->getResponse()->isSuccessful());
$content = $response->getContent();
self::assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
self::assertStringContainsString('<title>Kimai</title>', $content);
self::assertStringContainsString('<form action="/en/login_check" method="post"', $content);
self::assertStringContainsString('<input autocomplete="username" type="text" id="username" name="_username"', $content);
self::assertStringContainsString('<input autocomplete="new-password" id="password" name="_password" type="password"', $content);

View File

@@ -56,7 +56,7 @@ class SelfRegistrationControllerTest extends AbstractControllerBaseTestCase
self::assertTrue($response->isSuccessful());
$content = $response->getContent();
self::assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
self::assertStringContainsString('<title>Kimai</title>', $content);
self::assertStringContainsString('Register a new account', $content);
self::assertStringContainsString('<form name="user_registration_form" method="post" action="/en/register/"', $content);
self::assertStringContainsString('<input type="email"', $content);
@@ -115,7 +115,7 @@ class SelfRegistrationControllerTest extends AbstractControllerBaseTestCase
$this->createUser($client, 'example', 'register@example.com', 'test1234');
$content = $client->getResponse()->getContent();
self::assertStringContainsString('<title>Kimai Time Tracking</title>', $content);
self::assertStringContainsString('<title>Kimai</title>', $content);
self::assertStringContainsString('An e-mail has been sent to register@example.com. It contains a link you must click to activate your account.', $content);
self::assertStringContainsString('<a href="/en/login">', $content);
}

View File

@@ -21,14 +21,14 @@ class ThemeJavascriptTranslationsEventTest extends TestCase
public function testDefaultValues(): void
{
$sut = new ThemeJavascriptTranslationsEvent();
$sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
self::assertCount(self::COUNTER, $sut->getTranslations());
}
public function testGetterAndSetter(): void
{
$sut = new ThemeJavascriptTranslationsEvent();
$sut = new ThemeJavascriptTranslationsEvent(); // @phpstan-ignore new.deprecated
$sut->setTranslation('foo', 'bar');
$sut->setTranslation('hello', 'world', 'testing');

View File

@@ -0,0 +1,34 @@
<?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\Event;
use App\Entity\User;
use App\Event\WorkingTimeApproveMonthEvent;
use App\WorkingTime\Model\Month;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\WorkingTimeApproveMonthEvent
*/
class WorkingTimeApproveMonthEventTest extends TestCase
{
public function testGetter(): void
{
$user = new User();
$approver = new User();
$approvalMonth = new Month(new \DateTimeImmutable('2023-02-10'), $user);
$sut = new WorkingTimeApproveMonthEvent($approvalMonth, $approver);
self::assertSame($user, $sut->getUser()); // @phpstan-ignore method.deprecated
self::assertSame($approvalMonth, $sut->getMonth());
self::assertSame($approver, $sut->getApprovedBy());
}
}

View File

@@ -0,0 +1,33 @@
<?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\Event;
use App\Entity\User;
use App\Event\WorkingTimeUnlockMonthEvent;
use App\WorkingTime\Model\Month;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\WorkingTimeUnlockMonthEvent
*/
class WorkingTimeUnlockMonthEventTest extends TestCase
{
public function testGetter(): void
{
$user = new User();
$approver = new User();
$approvalMonth = new Month(new \DateTimeImmutable('2023-02-10'), $user);
$sut = new WorkingTimeUnlockMonthEvent($approvalMonth, $approver);
self::assertSame($approvalMonth, $sut->getMonth());
self::assertSame($approver, $sut->getUnlockedBy());
}
}

View File

@@ -22,7 +22,7 @@ abstract class AbstractActionsSubscriberTestCase extends TestCase
protected function createSubscriber(string $className, ...$grants): AbstractActionsSubscriber
{
$auth = $this->createMock(AuthorizationCheckerInterface::class);
$auth->method('isGranted')->willReturnOnConsecutiveCalls(...$grants);
$auth->method('isGranted')->willReturnOnConsecutiveCalls(...$grants); // @phpstan-ignore argument.named
$router = $this->createMock(UrlGeneratorInterface::class);
$router->method('generate')->willReturnArgument(0);

View File

@@ -27,6 +27,7 @@ class EmailSubscriberTest extends TestCase
$events = EmailSubscriber::getSubscribedEvents();
self::assertArrayHasKey(EmailEvent::class, $events);
$methodName = $events[EmailEvent::class][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(EmailSubscriber::class, $methodName));
}

View File

@@ -31,10 +31,12 @@ class LastLoginSubscriberTest extends TestCase
self::assertArrayHasKey(UserInteractiveLoginEvent::class, $events);
$methodName = $events[UserInteractiveLoginEvent::class];
self::assertIsString($methodName);
self::assertTrue(method_exists(LastLoginSubscriber::class, $methodName));
self::assertArrayHasKey(LoginSuccessEvent::class, $events);
$methodName = $events[LoginSuccessEvent::class];
self::assertIsString($methodName);
self::assertTrue(method_exists(LastLoginSubscriber::class, $methodName));
}

View File

@@ -23,6 +23,7 @@ class MenuSubscriberTest extends TestCase
$events = MenuSubscriber::getSubscribedEvents();
self::assertArrayHasKey(ConfigureMainMenuEvent::class, $events);
$methodName = $events[ConfigureMainMenuEvent::class][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(MenuSubscriber::class, $methodName));
}
}

View File

@@ -29,6 +29,7 @@ class PagerfantaExceptionSubscriberTest extends TestCase
$events = PagerfantaExceptionSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::EXCEPTION, $events);
$methodName = $events[KernelEvents::EXCEPTION][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(PagerfantaExceptionSubscriber::class, $methodName));
}

View File

@@ -33,6 +33,7 @@ class ProfileSubscriberTest extends TestCase
self::assertArrayHasKey(LoginSuccessEvent::class, $events);
$methodName = $events[LoginSuccessEvent::class];
self::assertIsString($methodName);
self::assertTrue(method_exists(LastLoginSubscriber::class, $methodName));
}

View File

@@ -23,6 +23,7 @@ class UserDetailsSubscriberTest extends TestCase
$events = UserDetailsSubscriber::getSubscribedEvents();
self::assertArrayHasKey(UserDetailsEvent::class, $events);
$methodName = $events[UserDetailsEvent::class][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(UserDetailsSubscriber::class, $methodName));
}
}

View File

@@ -44,6 +44,7 @@ class UserPreferenceSubscriberTest extends TestCase
$events = UserPreferenceSubscriber::getSubscribedEvents();
self::assertArrayHasKey(PrepareUserEvent::class, $events);
$methodName = $events[PrepareUserEvent::class][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(UserPreferenceSubscriber::class, $methodName));
}

View File

@@ -23,6 +23,7 @@ class WizardSubscriberTest extends TestCase
$events = WizardSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
$methodName = $events[KernelEvents::REQUEST][0];
self::assertIsString($methodName);
self::assertTrue(method_exists(WizardSubscriber::class, $methodName));
}
}

View File

@@ -109,6 +109,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'0',
'0',
'84',
'Kevin',
'kevin',
'',
'Customer Name',
@@ -142,6 +143,7 @@ class CsvRendererTest extends AbstractRendererTestCase
'0',
'0',
'-100.92',
'niveK',
'nivek',
'',
'Customer Name',
@@ -168,6 +170,6 @@ class CsvRendererTest extends AbstractRendererTestCase
self::assertEquals($expected, $all[5]);
self::assertEquals($expected2, $all[6]);
self::assertEquals(\count($expected), \count($all[0]));
self::assertEquals('foo', $all[4][16]);
self::assertEquals('foo', $all[4][17]);
}
}

View File

@@ -52,17 +52,19 @@ abstract class AbstractRendererTestCase extends KernelTestCase
$activity->setProject($project);
$activity->setMetaField((new ActivityMeta())->setName('activity-foo')->setValue('activity-bar')->setIsVisible(true));
$userMethods = ['getId', 'getPreferenceValue', 'getUsername', 'getUserIdentifier'];
$userMethods = ['getId', 'getPreferenceValue', 'getUsername', 'getUserIdentifier', 'getAlias'];
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user1->method('getAlias')->willReturn('Foo Bar');
$user1->method('getUserIdentifier')->willReturn('foo-bar');
$user1->method('getUsername')->willReturn('foo-bar');
$user2 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
$user2->method('getId')->willReturn(2);
$user2->method('getUsername')->willReturn('hello-world');
$user2->method('getAlias')->willReturn('Hello World');
$user2->method('getUserIdentifier')->willReturn('hello-world');
$user2->method('getUsername')->willReturn('hello-world');
$timesheet = new Timesheet();
$timesheet
@@ -111,6 +113,7 @@ abstract class AbstractRendererTestCase extends KernelTestCase
;
$userKevin = new User();
$userKevin->setAlias('Kevin');
$userKevin->setUserIdentifier('kevin');
$timesheet5 = new Timesheet();
@@ -129,6 +132,7 @@ abstract class AbstractRendererTestCase extends KernelTestCase
;
$userNivek = new User();
$userNivek->setAlias('niveK');
$userNivek->setUserIdentifier('nivek');
$timesheet6 = new Timesheet();

View File

@@ -32,6 +32,8 @@ class InvoiceModelTest extends TestCase
self::assertNotNull($sut->getCustomer());
self::assertNotNull($sut->getTemplate());
self::assertFalse($sut->isPreview());
self::assertFalse($sut->isHideZeroTax());
self::assertNull($sut->getCalculator());
self::assertEmpty($sut->getEntries());
self::assertIsArray($sut->getEntries());
@@ -43,6 +45,12 @@ class InvoiceModelTest extends TestCase
$sut->setFormatter($newFormatter);
self::assertNotSame($formatter, $sut->getFormatter());
self::assertSame($newFormatter, $sut->getFormatter());
self::assertEquals([], $sut->getOptions());
$sut->setPreview(true);
$sut->setHideZeroTax(true);
self::assertTrue($sut->isPreview());
self::assertTrue($sut->isHideZeroTax());
}
public function testEmptyObjectThrowsExceptionOnNumberGenerator(): void
@@ -75,6 +83,23 @@ class InvoiceModelTest extends TestCase
self::assertSame($template, $sut->getTemplate());
self::assertInstanceOf(\DateTimeInterface::class, $sut->getDueDate());
$sut->setOption('foo-int', 123);
$sut->setOption('foo-float', 123.45);
$sut->setOption('foo-string', '12345');
$sut->setOption('foo-null', null);
$sut->setOption('foo-bool', true);
$sut->setOption('foo-array', ['foo' => 'bar']);
$sut->setOption('foo-array2', ['foo', 'bar']);
self::assertEquals([
'foo-int' => 123,
'foo-float' => 123.45,
'foo-string' => '12345',
'foo-null' => null,
'foo-bool' => true,
'foo-array' => ['foo' => 'bar'],
'foo-array2' => ['foo', 'bar']
], $sut->getOptions());
}
public function testDueDate(): void

View File

@@ -50,7 +50,7 @@ class ThemeEventExtensionTest extends TestCase
];
}
protected function getSut(bool $hasListener = true, string $title = null): ThemeExtension
protected function getSut(bool $hasListener = true): ThemeExtension
{
$dispatcher = $this->createMock(EventDispatcherInterface::class);
$dispatcher->method('hasListeners')->willReturn($hasListener);
@@ -155,18 +155,6 @@ class ThemeEventExtensionTest extends TestCase
public function testGetTitle(): void
{
$sut = $this->getSut(false);
self::assertEquals('Kimai foo', $sut->generateTitle());
self::assertEquals('sdfsdf | Kimai foo', $sut->generateTitle('sdfsdf | '));
self::assertEquals('<b>Kimai</b> ... foo', $sut->generateTitle('<b>', '</b> ... '));
self::assertEquals('Kimai | foo', $sut->generateTitle(null, ' | '));
}
public function testGetBrandedTitle(): void
{
$sut = $this->getSut(false, 'MyCompany');
self::assertEquals('Kimai foo', $sut->generateTitle());
self::assertEquals('sdfsdf | Kimai foo', $sut->generateTitle('sdfsdf | '));
self::assertEquals('<b>Kimai</b> ... foo', $sut->generateTitle('<b>', '</b> ... '));
self::assertEquals('Kimai | foo', $sut->generateTitle(null, ' | '));
self::assertEquals('Kimai', $sut->generateTitle());
}
}

View File

@@ -1311,51 +1311,11 @@ parameters:
count: 1
path: EventSubscriber/Actions/AbstractActionsSubscriberTestCase.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/EmailSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, array\\<int\\|string, int\\|string\\>\\|int\\|string\\>\\|string given\\.$#"
count: 2
path: EventSubscriber/LastLoginSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/MenuSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/PagerfantaExceptionSubscriberTest.php
-
message: "#^Method App\\\\Tests\\\\EventSubscriber\\\\ProfileSubscriberTest\\:\\:getInvalidCookies\\(\\) has no return type specified\\.$#"
count: 1
path: EventSubscriber/ProfileSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, array\\<int\\|string, int\\|string\\>\\|int\\|string\\>\\|string given\\.$#"
count: 1
path: EventSubscriber/ProfileSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/UserDetailsSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/UserPreferenceSubscriberTest.php
-
message: "#^Parameter \\#2 \\$method of function method_exists expects string, array\\<int\\|string, int\\|string\\>\\|int\\|string given\\.$#"
count: 1
path: EventSubscriber/WizardSubscriberTest.php
-
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
count: 6

View File

@@ -614,6 +614,14 @@
<source>complete_month.help</source>
<target>Dies wird alle Tage des Jahres vor dem gewählten Datum abschliessen. Der Benutzer wird für den abgeschlossenen Zeitraum keine Zeiten mehr anlegen oder bearbeiten können.</target>
</trans-unit>
<trans-unit id="4WlKIfD" resname="unlock_month" xml:space="preserve" approved="yes">
<source>Unlock month</source>
<target state="final">Monat entsperren</target>
</trans-unit>
<trans-unit id="mhOe3Iy" resname="unlock_month.help">
<source>unlock_month.help</source>
<target>Dadurch werden alle Tage des Jahres nach dem ausgewählten Datum entsperrt.</target>
</trans-unit>
<trans-unit id="eWmotjb" resname="completed_month_pdf">
<source>completed_month_pdf</source>
<target>Monats-Abschluss PDF</target>

View File

@@ -614,6 +614,14 @@
<source>complete_month.help</source>
<target>This will lock all days of the year before the chosen date. The user will no longer be able to create or edit times for the locked period.</target>
</trans-unit>
<trans-unit id="4WlKIfD" resname="unlock_month" xml:space="preserve" approved="yes">
<source>Unlock month</source>
<target state="final">Unlock month</target>
</trans-unit>
<trans-unit id="mhOe3Iy" resname="unlock_month.help">
<source>unlock_month.help</source>
<target>This unlocks all days of the year after the selected date.</target>
</trans-unit>
<trans-unit id="eWmotjb" resname="completed_month_pdf">
<source>completed_month_pdf</source>
<target>Completed month PDF</target>

View File

@@ -18,6 +18,10 @@
<source>Plugin Shop</source>
<target>Mehr Erweiterungen</target>
</trans-unit>
<trans-unit id="aNvGEaj" resname="update_available" xml:space="preserve">
<source>Update available</source>
<target>Update verfügbar: %version%</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -18,6 +18,10 @@
<source>Plugin Shop</source>
<target>More extensions</target>
</trans-unit>
<trans-unit id="aNvGEaj" resname="update_available" xml:space="preserve">
<source>Update available</source>
<target>Update available: %version%</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -158,6 +158,10 @@
<source>An absence cannot be booked after the last working day.</source>
<target>Eine Abwesenheit kann nicht nach dem letzten Arbeitstag gebucht werden.</target>
</trans-unit>
<trans-unit id="mveJ4vW" resname="Selected period cannot be locked: unconfirmed absence requests are pending.">
<source>Selected period cannot be locked: unconfirmed absence requests are pending.</source>
<target>Ausgewählter Zeitraum kann nicht gesperrt werden: unbestätigte Abwesenheitsanträge stehen an.</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -158,6 +158,10 @@
<source>An absence cannot be booked after the last working day.</source>
<target>An absence cannot be booked after the last working day.</target>
</trans-unit>
<trans-unit id="mveJ4vW" resname="Selected period cannot be locked: unconfirmed absence requests are pending.">
<source>Selected period cannot be locked: unconfirmed absence requests are pending.</source>
<target>Selected period cannot be locked: unconfirmed absence requests are pending.</target>
</trans-unit>
</body>
</file>
</xliff>