Release 2.22.0 (#5043)
This commit is contained in:
@@ -117,7 +117,7 @@ class ActivityStatisticService
|
||||
|
||||
/**
|
||||
* @param Activity[] $activities
|
||||
* @return array<int, ActivityStatistic>
|
||||
* @return array<int|string, ActivityStatistic>
|
||||
*/
|
||||
private function getBudgetStatistic(array $activities, ?DateTimeInterface $begin = null, ?DateTimeInterface $end = null): array
|
||||
{
|
||||
@@ -159,6 +159,9 @@ class ActivityStatisticService
|
||||
return $statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Activity[] $activities
|
||||
*/
|
||||
private function createStatisticQueryBuilder(array $activities, \DateTimeInterface $begin = null, ?\DateTimeInterface $end = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->timesheetRepository->createQueryBuilder('t');
|
||||
|
||||
@@ -17,11 +17,11 @@ class Constants
|
||||
/**
|
||||
* The current release version
|
||||
*/
|
||||
public const VERSION = '2.21.0';
|
||||
public const VERSION = '2.22.0';
|
||||
/**
|
||||
* The current release: major * 10000 + minor * 100 + patch
|
||||
*/
|
||||
public const VERSION_ID = 22100;
|
||||
public const VERSION_ID = 22200;
|
||||
/**
|
||||
* The software name
|
||||
*/
|
||||
|
||||
@@ -53,7 +53,7 @@ final class ContractController extends AbstractController
|
||||
|
||||
/** @var User $profile */
|
||||
$profile = $values->getUser();
|
||||
if ($this->getUser() !== $profile && !$canChangeUser) {
|
||||
if (!$this->isGranted('hours', $profile)) {
|
||||
throw $this->createAccessDeniedException('Cannot access user contract settings');
|
||||
}
|
||||
|
||||
@@ -79,9 +79,10 @@ final class ContractController extends AbstractController
|
||||
|
||||
$boxConfiguration = new BoxConfiguration();
|
||||
$boxConfiguration->setDecimal(false);
|
||||
$boxConfiguration->setCollapsed($profile->hasWorkHourConfiguration() && $summary->count() > 0);
|
||||
$boxConfiguration->setCollapsed($summary->count() > 0);
|
||||
|
||||
return $this->render('contract/status.html.twig', [
|
||||
'withWorkHourConfiguration' => $profile->hasWorkHourConfiguration(),
|
||||
'box_configuration' => $boxConfiguration,
|
||||
'page_setup' => $page,
|
||||
'decimal' => $boxConfiguration->isDecimal(),
|
||||
|
||||
@@ -61,9 +61,8 @@ final class TeamController extends AbstractController
|
||||
$table->setReloadEvents('kimai.teamUpdate');
|
||||
|
||||
$table->addColumn('name', ['class' => 'alwaysVisible']);
|
||||
$table->addColumn('teamlead', ['class' => 'd-none badges', 'orderBy' => false]);
|
||||
$table->addColumn('teamlead_avatar', ['title' => 'team.member', 'translation_domain' => 'teams', 'class' => 'd-none d-lg-table-cell avatars avatar-list avatar-list-stacked', 'orderBy' => false]);
|
||||
$table->addColumn('user', ['class' => 'd-none badges', 'orderBy' => false, 'title' => 'user']);
|
||||
$table->addColumn('avatar', ['title' => 'team.member', 'translation_domain' => 'teams', 'class' => 'd-none d-sm-table-cell avatars avatar-list avatar-list-stacked', 'orderBy' => false]);
|
||||
$table->addColumn('amount', ['title' => 'amount', 'class' => 'd-sm-none text-center', 'orderBy' => false]);
|
||||
$table->addColumn('actions', ['class' => 'actions']);
|
||||
|
||||
$page = new PageSetup('teams');
|
||||
|
||||
@@ -1117,9 +1117,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
|
||||
$initial = mb_substr($initial, 0, $length, 'UTF-8');
|
||||
}
|
||||
|
||||
$initial = mb_strtoupper($initial);
|
||||
|
||||
return $initial;
|
||||
return mb_strtoupper($initial);
|
||||
}
|
||||
|
||||
public function getAccountNumber(): ?string
|
||||
|
||||
@@ -71,6 +71,9 @@ final class ConfigureMainMenuEvent extends Event
|
||||
return $this->menu->getChild('reporting');
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 2.22 - use getMenu() or getAdminMenu() instead
|
||||
*/
|
||||
public function getAppsMenu(): MenuItemModel
|
||||
{
|
||||
return $this->apps;
|
||||
|
||||
@@ -17,7 +17,11 @@ use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
final class UserSubscriber extends AbstractActionsSubscriber
|
||||
{
|
||||
public function __construct(AuthorizationCheckerInterface $auth, UrlGeneratorInterface $urlGenerator, private EventDispatcherInterface $eventDispatcher)
|
||||
public function __construct(
|
||||
AuthorizationCheckerInterface $auth,
|
||||
UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EventDispatcherInterface $eventDispatcher
|
||||
)
|
||||
{
|
||||
parent::__construct($auth, $urlGenerator);
|
||||
}
|
||||
@@ -47,6 +51,10 @@ final class UserSubscriber extends AbstractActionsSubscriber
|
||||
$event->addActionToSubmenu('edit', $id, $action);
|
||||
}
|
||||
|
||||
if ($this->isGranted('hours', $user)) {
|
||||
$event->addActionToSubmenu('report', 'work_times', ['url' => $this->path('user_contract', ['user' => $user->getId()]), 'title' => 'work_times']);
|
||||
}
|
||||
|
||||
if (($event->getUser()->getId() === $user->getId() && $this->isGranted('report:user')) || $this->isGranted('report:other')) {
|
||||
$event->addActionToSubmenu('report', 'weekly', ['url' => $this->path('report_user_week', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_week']);
|
||||
$event->addActionToSubmenu('report', 'monthly', ['url' => $this->path('report_user_month', ['user' => $user->getId()]), 'translation_domain' => 'reporting', 'title' => 'report_user_month']);
|
||||
|
||||
@@ -43,8 +43,8 @@ final class MenuBuilderSubscriber implements EventSubscriberInterface
|
||||
$event->addItem($child);
|
||||
}
|
||||
|
||||
if ($menuEvent->getAppsMenu()->hasChildren()) {
|
||||
$event->addItem($menuEvent->getAppsMenu());
|
||||
if ($menuEvent->getAppsMenu()->hasChildren()) { // @phpstan-ignore-line
|
||||
$event->addItem($menuEvent->getAppsMenu()); // @phpstan-ignore-line
|
||||
}
|
||||
if ($menuEvent->getAdminMenu()->hasChildren()) {
|
||||
$event->addItem($menuEvent->getAdminMenu());
|
||||
|
||||
@@ -94,7 +94,7 @@ final class MenuSubscriber implements EventSubscriberInterface
|
||||
}
|
||||
|
||||
$contract = new MenuItemModel('contract', 'work_contract', null, [], 'contract');
|
||||
if ($user->hasContractSettings() || $auth->isGranted('hours_other_profile')) {
|
||||
if ($auth->isGranted('hours', $user)) {
|
||||
$contract->addChild(new MenuItemModel('contract_status', 'work_times', 'user_contract', [], 'work_times'));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,19 +25,11 @@ final class DocumentationLinkExtension extends AbstractTypeExtension
|
||||
return [FormType::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormView $view
|
||||
* @param FormInterface $form
|
||||
* @param array $options
|
||||
*/
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['docu_chapter'] = $options['docu_chapter'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param OptionsResolver $resolver
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefined(['docu_chapter']);
|
||||
|
||||
@@ -26,11 +26,6 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
return [EntityType::class, ChoiceType::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormView $view
|
||||
* @param FormInterface $form
|
||||
* @param array $options
|
||||
*/
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
if (isset($options['selectpicker']) && false === $options['selectpicker']) {
|
||||
@@ -59,7 +54,7 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
|
||||
|
||||
// there is a very weird logic in vendor/symfony/twig-bridge/Resources/views/Form/form_div_layout.html.twig
|
||||
// in block "block choice_widget_collapsed" that resets "{% set required = false %}", so we fake it into the select
|
||||
if (true === $options['required'] && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
|
||||
if (true === $options['required'] && \is_array($options['attr']) && (!\array_key_exists('size', $options['attr']) || $options['attr']['size'] <= 1)) {
|
||||
$extendedOptions['required'] = 'required';
|
||||
$extendedOptions['placeholder'] = '';
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@ final class IconExtension extends AbstractTypeExtension
|
||||
return [TextType::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormView $view
|
||||
* @param FormInterface $form
|
||||
* @param array $options
|
||||
*/
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
$view->vars['icon'] = $options['icon'] ?? null;
|
||||
|
||||
@@ -21,7 +21,7 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
*/
|
||||
final class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
{
|
||||
public function __construct(private UrlGeneratorInterface $router)
|
||||
public function __construct(private readonly UrlGeneratorInterface $router)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
return [EntityType::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormView $view
|
||||
* @param FormInterface $form
|
||||
* @param array $options
|
||||
*/
|
||||
public function buildView(FormView $view, FormInterface $form, array $options): void
|
||||
{
|
||||
if (!isset($options['api_data'])) {
|
||||
@@ -69,7 +64,7 @@ final class SelectWithApiDataExtension extends AbstractTypeExtension
|
||||
$parent = $form->getParent();
|
||||
do {
|
||||
$formPrefixes[] = $parent->getName();
|
||||
} while (($parent = $parent->getParent()) !== null);
|
||||
} while (($parent = $parent?->getParent()) !== null);
|
||||
|
||||
$formPrefix = implode('_', array_reverse($formPrefixes));
|
||||
$formField = $apiData['select'];
|
||||
|
||||
@@ -45,7 +45,7 @@ final class MenuChoiceType extends AbstractType
|
||||
$this->eventDispatcher->dispatch($event);
|
||||
|
||||
$choices = $this->getChoicesFromMenu($event->getMenu(), $filter);
|
||||
$choices += $this->getChoicesFromMenu($event->getAppsMenu(), $filter);
|
||||
$choices += $this->getChoicesFromMenu($event->getAppsMenu(), $filter); // @phpstan-ignore-line
|
||||
$choices += $this->getChoicesFromMenu($event->getAdminMenu(), $filter);
|
||||
$choices += $this->getChoicesFromMenu($event->getSystemMenu(), $filter);
|
||||
|
||||
|
||||
@@ -82,12 +82,14 @@ final class ActivityLoader implements LoaderInterface
|
||||
}
|
||||
|
||||
// required on "Activity listing" page for non super-admins
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'teams')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('a.id', $activityIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($activityIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL a.{id}', 'teams')
|
||||
->from(Activity::class, 'a')
|
||||
->leftJoin('a.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('a.id', $activityIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,15 @@ final class CustomerLoader implements LoaderInterface
|
||||
$em = $this->entityManager;
|
||||
|
||||
// required where we need to check team permissions, e.g. "Customer listing"
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'teams')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('c.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($customerIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL c.{id}', 'teams')
|
||||
->from(Customer::class, 'c')
|
||||
->leftJoin('c.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('c.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
if ($hydrateTeamMembers) {
|
||||
|
||||
@@ -37,6 +37,7 @@ final class ProjectLoader implements LoaderInterface
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var array<int> $projectIds */
|
||||
$projectIds = array_filter(array_unique(array_map(function (Project $project) {
|
||||
// make sure that this potential doctrine proxy is initialized and filled with all data
|
||||
$project->getName();
|
||||
@@ -48,7 +49,7 @@ final class ProjectLoader implements LoaderInterface
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
if ($this->hydrateTeams) {
|
||||
if ($this->hydrateTeams && \count($projectIds) > 0) {
|
||||
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
|
||||
return $project->getCustomer()->getId();
|
||||
}, $results)), function ($value) { return $value !== null; });
|
||||
@@ -61,13 +62,15 @@ final class ProjectLoader implements LoaderInterface
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL customer.{id}', 'teams')
|
||||
->from(Customer::class, 'customer')
|
||||
->leftJoin('customer.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('customer.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($customerIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL customer.{id}', 'teams')
|
||||
->from(Customer::class, 'customer')
|
||||
->leftJoin('customer.teams', 'teams')
|
||||
->andWhere($qb->expr()->in('customer.id', $customerIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
|
||||
// do not load team members or leads by default, because they will only be used on detail pages
|
||||
|
||||
@@ -45,24 +45,26 @@ final class TeamLoader implements LoaderInterface
|
||||
$em = $this->entityManager;
|
||||
|
||||
// required wherever users are shown, e.g. on "Custom details" page
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
if (\count($teamIds) > 0) {
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL team.{id}', 'members', 'user')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.members', 'members')
|
||||
->leftJoin('members.user', 'user')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
// used in UserTeamProjects widget
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var array<Team> $teams */
|
||||
$teams = $qb->select('PARTIAL team.{id}', 'projects')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.projects', 'projects')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
// used in UserTeamProjects widget
|
||||
$qb = $em->createQueryBuilder();
|
||||
/** @var array<Team> $teams */
|
||||
$teams = $qb->select('PARTIAL team.{id}', 'projects')
|
||||
->from(Team::class, 'team')
|
||||
->leftJoin('team.projects', 'projects')
|
||||
->andWhere($qb->expr()->in('team.id', $teamIds))
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
$projectIds = [];
|
||||
foreach ($results as $team) {
|
||||
@@ -71,7 +73,7 @@ final class TeamLoader implements LoaderInterface
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->loadCustomer) {
|
||||
if ($this->loadCustomer && \count($projectIds) > 0) {
|
||||
// used in UserTeamProjects widget
|
||||
$qb = $em->createQueryBuilder();
|
||||
$qb->select('PARTIAL project.{id}', 'customer')
|
||||
|
||||
@@ -135,8 +135,9 @@ class TagRepository extends EntityRepository
|
||||
$qb1 = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb1->from(Timesheet::class, 't')->select('COUNT(tags)')->innerJoin('t.tags', 'tags')->where('tags.id = tag.id');
|
||||
|
||||
$dql = $qb1->getDQL(); // see https://github.com/phpstan/phpstan-doctrine/issues/606
|
||||
$qb->select('tag.id, tag.name, tag.color, tag.visible');
|
||||
$qb->addSelect('(' . $qb1->getDQL() . ') as amount');
|
||||
$qb->addSelect('(' . $dql . ') as amount');
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
$orderBy = match ($orderBy) {
|
||||
|
||||
@@ -19,11 +19,7 @@ final class Util
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the rate for a hourly rate and a given duration in seconds.
|
||||
*
|
||||
* @param float $hourlyRate
|
||||
* @param int $seconds
|
||||
* @return float
|
||||
* Calculates the rate by an hourly rate and a given duration in seconds.
|
||||
*/
|
||||
public static function calculateRate(float $hourlyRate, int $seconds): float
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ use App\Utils\JavascriptFormatConverter;
|
||||
use App\Utils\LocaleFormatter;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\LocaleAwareInterface;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFilter;
|
||||
@@ -30,7 +29,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
private ?LocaleFormatter $formatter = null;
|
||||
private ?string $locale = null;
|
||||
|
||||
public function __construct(private LocaleService $localeService, private Security $security)
|
||||
public function __construct(private readonly LocaleService $localeService)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -111,7 +110,7 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
public function isWeekend(\DateTimeInterface|string|null $dateTime, ?User $user = null): bool
|
||||
public function isWeekend(\DateTimeInterface|string|null $dateTime): bool
|
||||
{
|
||||
if (!$dateTime instanceof \DateTimeInterface) {
|
||||
return false;
|
||||
@@ -119,12 +118,6 @@ final class LocaleFormatExtensions extends AbstractExtension implements LocaleAw
|
||||
|
||||
$day = (int) $dateTime->format('N');
|
||||
|
||||
/** @var User|null $tmp */
|
||||
$tmp = $user ?? $this->security->getUser();
|
||||
if ($tmp !== null && $tmp->hasWorkHourConfiguration()) {
|
||||
return !$tmp->isWorkDay($dateTime);
|
||||
}
|
||||
|
||||
return ($day === 6 || $day === 7);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,10 +74,6 @@ final class UserVoter extends Voter
|
||||
return $this->permissionManager->hasRolePermission($user, 'contract_other_profile');
|
||||
}
|
||||
|
||||
if ($attribute === 'hours') {
|
||||
return $this->permissionManager->hasRolePermission($user, 'hours_other_profile');
|
||||
}
|
||||
|
||||
if ($attribute === 'access_user') {
|
||||
return $user->canSeeUser($subject);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user