added API for handling work contract times (#4016)

This commit is contained in:
Kevin Papst
2023-05-13 01:24:15 +02:00
committed by GitHub
parent 4b2a3669e6
commit 43bd7bf1ab
53 changed files with 2684 additions and 27 deletions

View File

@@ -0,0 +1,93 @@
<?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\Controller;
use App\Entity\User;
use App\Event\WorkContractDetailControllerEvent;
use App\Form\ContractByUserForm;
use App\Reporting\YearByUser\YearByUser;
use App\Utils\PageSetup;
use App\WorkingTime\Model\BoxConfiguration;
use App\WorkingTime\WorkingTimeService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Users can control their working time statistics
*/
final class ContractController extends AbstractController
{
#[Route(path: '/contract', name: 'user_contract', methods: ['GET', 'POST'])]
public function __invoke(Request $request, WorkingTimeService $workingTimeService, EventDispatcherInterface $eventDispatcher): Response
{
$currentUser = $this->getUser();
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
$canChangeUser = $this->isGranted('contract_other_profile');
$defaultDate = $dateTimeFactory->createStartOfYear();
$values = new YearByUser();
$values->setUser($currentUser);
$values->setDate($defaultDate);
$form = $this->createFormForGetRequest(ContractByUserForm::class, $values, [
'include_user' => $canChangeUser,
'timezone' => $dateTimeFactory->getTimezone()->getName(),
'start_date' => $values->getDate(),
]);
$form->submit($request->query->all(), false);
if ($values->getUser() === null) {
$values->setUser($currentUser);
}
/** @var User $profile */
$profile = $values->getUser();
if ($this->getUser() !== $profile && !$canChangeUser) {
throw $this->createAccessDeniedException('Cannot access user contract settings');
}
if ($values->getDate() === null) {
$values->setDate(clone $defaultDate);
}
/** @var \DateTime $yearDate */
$yearDate = $values->getDate();
$year = $workingTimeService->getYear($profile, $yearDate);
$page = new PageSetup('status');
$page->setHelp('contract.html');
// additional boxes by plugins
$controllerEvent = new WorkContractDetailControllerEvent($year);
$eventDispatcher->dispatch($controllerEvent);
$now = $dateTimeFactory->createDateTime();
$summary = $workingTimeService->getYearSummary($year, $now);
$boxConfiguration = new BoxConfiguration();
$boxConfiguration->setDecimal(false);
$boxConfiguration->setCollapsed($profile->hasWorkHourConfiguration() && $summary->count() > 0);
return $this->render('contract/status.html.twig', [
'box_configuration' => $boxConfiguration,
'page_setup' => $page,
'decimal' => $boxConfiguration->isDecimal(),
'summaries' => $summary,
'now' => $now,
'boxes' => $controllerEvent->getController(),
'year' => $year,
'user' => $profile,
'form' => $form->createView(),
]);
}
}

View File

@@ -14,6 +14,7 @@ use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\Model\TotpActivation;
use App\Form\UserApiTokenType;
use App\Form\UserContractType;
use App\Form\UserEditType;
use App\Form\UserPasswordType;
use App\Form\UserPreferencesForm;
@@ -178,6 +179,32 @@ final class ProfileController extends AbstractController
]);
}
#[Route(path: '/{username}/contract', name: 'user_profile_contract', methods: ['GET', 'POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[IsGranted('contract', 'profile')]
public function contractAction(User $profile, Request $request, UserRepository $userRepository): Response
{
$form = $this->createForm(UserContractType::class, $profile, [
'action' => $this->generateUrl('user_profile_contract', ['username' => $profile->getUserIdentifier()]),
'method' => 'POST',
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$userRepository->saveUser($profile);
$this->flashSuccess('action.update.success');
return $this->redirectToRoute('user_profile_contract', ['username' => $profile->getUserIdentifier()]);
}
return $this->render('user/contract.html.twig', [
'tab' => 'contract',
'user' => $profile,
'form' => $form->createView(),
]);
}
#[Route(path: '/{username}/teams', name: 'user_profile_teams', methods: ['GET', 'POST'])]
#[IsGranted('teams', 'profile')]
public function teamsAction(User $profile, Request $request, UserRepository $userRepository, TeamRepository $teamRepository): Response

View File

@@ -1133,4 +1133,114 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
{
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
public function getWorkHoursMonday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_MONDAY, 0);
}
public function getWorkHoursTuesday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_TUESDAY, 0);
}
public function getWorkHoursWednesday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_WEDNESDAY, 0);
}
public function getWorkHoursThursday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_THURSDAY, 0);
}
public function getWorkHoursFriday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_FRIDAY, 0);
}
public function getWorkHoursSaturday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_SATURDAY, 0);
}
public function getWorkHoursSunday(): int
{
return (int) $this->getPreferenceValue(UserPreference::WORK_HOURS_SUNDAY, 0);
}
public function getHolidaysPerYear(): int
{
return (int) $this->getPreferenceValue(UserPreference::HOLIDAYS_PER_YEAR, 0);
}
public function setWorkHoursMonday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_MONDAY, $seconds);
}
public function setWorkHoursTuesday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_TUESDAY, $seconds);
}
public function setWorkHoursWednesday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_WEDNESDAY, $seconds);
}
public function setWorkHoursThursday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_THURSDAY, $seconds);
}
public function setWorkHoursFriday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_FRIDAY, $seconds);
}
public function setWorkHoursSaturday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_SATURDAY, $seconds);
}
public function setWorkHoursSunday(int $seconds): void
{
$this->setPreferenceValue(UserPreference::WORK_HOURS_SUNDAY, $seconds);
}
public function setHolidaysPerYear(int $holidays): void
{
$this->setPreferenceValue(UserPreference::HOLIDAYS_PER_YEAR, $holidays);
}
public function hasContractSettings(): bool
{
return $this->hasWorkHourConfiguration() || $this->getHolidaysPerYear() !== 0;
}
public function hasWorkHourConfiguration(): bool
{
return $this->getWorkHoursMonday() !== 0 ||
$this->getWorkHoursTuesday() !== 0 ||
$this->getWorkHoursWednesday() !== 0 ||
$this->getWorkHoursThursday() !== 0 ||
$this->getWorkHoursFriday() !== 0 ||
$this->getWorkHoursSaturday() !== 0 ||
$this->getWorkHoursSunday() !== 0;
}
public function getWorkHoursForDay(\DateTimeInterface $dateTime): int
{
return match ($dateTime->format('N')) {
'1' => $this->getWorkHoursMonday(),
'2' => $this->getWorkHoursTuesday(),
'3' => $this->getWorkHoursWednesday(),
'4' => $this->getWorkHoursThursday(),
'5' => $this->getWorkHoursFriday(),
'6' => $this->getWorkHoursSaturday(),
'7' => $this->getWorkHoursSunday(),
default => throw new \Exception('Unknown day: ' . $dateTime->format('Y-m-d'))
};
}
}

View File

@@ -31,6 +31,14 @@ class UserPreference
public const LOCALE = 'language';
public const TIMEZONE = 'timezone';
public const FIRST_WEEKDAY = 'first_weekday';
public const WORK_HOURS_MONDAY = 'work_monday';
public const WORK_HOURS_TUESDAY = 'work_tuesday';
public const WORK_HOURS_WEDNESDAY = 'work_wednesday';
public const WORK_HOURS_THURSDAY = 'work_thursday';
public const WORK_HOURS_FRIDAY = 'work_friday';
public const WORK_HOURS_SATURDAY = 'work_saturday';
public const WORK_HOURS_SUNDAY = 'work_sunday';
public const HOLIDAYS_PER_YEAR = 'holidays';
#[ORM\Id]
#[ORM\GeneratedValue]

113
src/Entity/WorkingTime.php Normal file
View File

@@ -0,0 +1,113 @@
<?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\Entity;
use App\Repository\WorkingTimeRepository;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_working_times')]
#[ORM\UniqueConstraint(columns: ['user_id', 'date'])]
#[ORM\Entity(repositoryClass: WorkingTimeRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
class WorkingTime
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(name: 'id', type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: 'App\Entity\User')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull]
private ?User $user = null;
#[ORM\Column(name: 'date', type: 'date', nullable: false)]
#[Assert\NotNull]
private \DateTimeInterface $date;
#[ORM\Column(name: 'expected', type: 'integer', nullable: false)]
#[Assert\NotNull]
private int $expectedTime = 0;
#[ORM\Column(name: 'actual', type: 'integer', nullable: false)]
#[Assert\NotNull]
private int $actualTime = 0;
#[ORM\ManyToOne(targetEntity: 'App\Entity\User')]
#[ORM\JoinColumn(name: 'approved_by', nullable: true, onDelete: 'SET NULL')]
private ?User $approvedBy = null;
#[ORM\Column(name: 'approved_at', type: 'datetime', nullable: true)]
#[Assert\NotNull]
private ?\DateTimeInterface $approvedAt = null;
public function __construct(User $user, \DateTimeInterface $date)
{
$this->user = $user;
$this->date = $date;
}
public function getId(): ?int
{
return $this->id;
}
public function getUser(): ?User
{
return $this->user;
}
public function getDate(): \DateTimeInterface
{
return $this->date;
}
public function getExpectedTime(): int
{
return $this->expectedTime;
}
public function setExpectedTime(int $expectedTime): void
{
$this->expectedTime = $expectedTime;
}
public function getActualTime(): int
{
return $this->actualTime;
}
public function setActualTime(int $actualTime): void
{
$this->actualTime = $actualTime;
}
public function getApprovedBy(): ?User
{
return $this->approvedBy;
}
public function setApprovedBy(?User $approvedBy): void
{
$this->approvedBy = $approvedBy;
}
public function getApprovedAt(): ?\DateTimeInterface
{
return $this->approvedAt;
}
public function setApprovedAt(?\DateTimeInterface $approvedAt): void
{
$this->approvedAt = $approvedAt;
}
public function isApproved(): bool
{
return $this->approvedAt !== null;
}
}

View File

@@ -0,0 +1,48 @@
<?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\WorkingTime\Model\Year;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Triggered for work-contract status pages, to add additional content boxes.
*
* @see https://symfony.com/doc/current/templates.html#embedding-controllers
*/
final class WorkContractDetailControllerEvent extends Event
{
/**
* @var array<string>
*/
private array $controller = [];
public function __construct(private Year $year)
{
}
public function getYear(): Year
{
return $this->year;
}
public function addController(string $controller): void
{
$this->controller[] = $controller;
}
/**
* @return string[]
*/
public function getController(): array
{
return $this->controller;
}
}

View File

@@ -0,0 +1,37 @@
<?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\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary;
use App\WorkingTime\Model\YearSummary;
use Symfony\Contracts\EventDispatcher\Event;
final class WorkingTimeYearSummaryEvent extends Event
{
public function __construct(private YearPerUserSummary $yearPerUserSummary, private \DateTimeInterface $until)
{
}
public function getYear(): Year
{
return $this->yearPerUserSummary->getYear();
}
public function getUntil(): \DateTimeInterface
{
return $this->until;
}
public function addSummary(YearSummary $yearSummary): void
{
$this->yearPerUserSummary->addSummary($yearSummary);
}
}

View File

@@ -31,7 +31,7 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
}
if ($this->isGranted('edit', $user)) {
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'edit', 'translation_domain' => 'actions']);
$event->addAction('edit', ['url' => $this->path('user_profile_edit', ['username' => $user->getUserIdentifier()]), 'title' => 'profile-stats', 'translation_domain' => 'actions']);
}
if ($this->isGranted('password', $user)) {
$event->addAction('password', ['url' => $this->path('user_profile_password', ['username' => $user->getUserIdentifier()]), 'title' => 'profile.password']);
@@ -48,5 +48,8 @@ final class UserFormsSubscriber extends AbstractActionsSubscriber
if ($this->isGranted('roles', $user)) {
$event->addAction('roles', ['url' => $this->path('user_profile_roles', ['username' => $user->getUserIdentifier()]), 'title' => 'profile.roles']);
}
if ($this->isGranted('contract', $user)) {
$event->addAction('contract', ['url' => $this->path('user_profile_contract', ['username' => $user->getUserIdentifier()]), 'title' => 'work_contract']);
}
}
}

View File

@@ -9,15 +9,16 @@
namespace App\EventSubscriber;
use App\Entity\User;
use App\Event\ConfigureMainMenuEvent;
use App\Utils\MenuItemModel;
use KevinPapst\TablerBundle\Helper\ContextHelper;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
final class MenuSubscriber implements EventSubscriberInterface
{
public function __construct(private AuthorizationCheckerInterface $security, private ContextHelper $helper)
public function __construct(private Security $security, private ContextHelper $helper)
{
}
@@ -45,6 +46,8 @@ final class MenuSubscriber implements EventSubscriberInterface
// main menu
$menu = $event->getMenu();
/** @var User $user */
$user = $auth->getUser();
$menu->addChild(new MenuItemModel('dashboard', 'dashboard.title', 'dashboard', [], 'dashboard'));
$menu->addChild(new MenuItemModel('favorites', 'favorite_routes', null, [], 'bookmarked'));
@@ -87,6 +90,15 @@ final class MenuSubscriber implements EventSubscriberInterface
$menu->addChild($times);
}
$contract = new MenuItemModel('contract', 'work_contract', null, [], 'contract');
if ($user->hasContractSettings() || $auth->isGranted('contract_other_profile')) {
$contract->addChild(new MenuItemModel('contract_status', 'work_times', 'user_contract', [], 'work_times'));
}
if ($contract->hasChildren()) {
$menu->addChild($contract);
}
if ($auth->isGranted('view_reporting')) {
$reporting = new MenuItemModel('reporting', 'menu.reporting', 'reporting', [], 'reporting');
$reporting->setChildRoutes(['report_user_week', 'report_user_month', 'report_weekly_users', 'report_monthly_users', 'report_project_view']);

View File

@@ -0,0 +1,48 @@
<?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\Form;
use App\Form\Type\UserType;
use App\Form\Type\YearPickerType;
use App\Reporting\YearByUser\YearByUser;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @internal
*/
final class ContractByUserForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('date', YearPickerType::class, [
'model_timezone' => $options['timezone'],
'view_timezone' => $options['timezone'],
'start_date' => $options['start_date'],
]);
if ($options['include_user']) {
$builder->add('user', UserType::class, ['width' => false]);
}
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => YearByUser::class,
'timezone' => date_default_timezone_get(),
'start_date' => new \DateTime(),
'include_user' => false,
'csrf_protection' => false,
'method' => 'GET',
]);
}
}

View File

@@ -0,0 +1,63 @@
<?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\Form;
use App\Entity\User;
use App\Form\Type\DurationType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
use Symfony\Component\Validator\Constraints\Range;
/**
* @extends AbstractType<User>
*/
final class UserContractType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$dayOptions = [
'translation_domain' => 'system-configuration',
'constraints' => [
new GreaterThanOrEqual(0)
],
];
$builder
->add('workHoursMonday', DurationType::class, array_merge(['label' => 'Monday'], $dayOptions))
->add('workHoursTuesday', DurationType::class, array_merge(['label' => 'Tuesday'], $dayOptions))
->add('workHoursWednesday', DurationType::class, array_merge(['label' => 'Wednesday'], $dayOptions))
->add('workHoursThursday', DurationType::class, array_merge(['label' => 'Thursday'], $dayOptions))
->add('workHoursFriday', DurationType::class, array_merge(['label' => 'Friday'], $dayOptions))
->add('workHoursSaturday', DurationType::class, array_merge(['label' => 'Saturday'], $dayOptions))
->add('workHoursSunday', DurationType::class, array_merge(['label' => 'Sunday'], $dayOptions))
/*
->add('holidaysPerYear', IntegerType::class, [
'label' => false,
'constraints' => [
new Range(['min' => 0, 'max' => 365])
],
])
*/
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
'csrf_protection' => true,
'csrf_field_name' => '_token',
'csrf_token_id' => 'edit_user_contract',
]);
}
}

24
src/Model/Day.php Normal file
View File

@@ -0,0 +1,24 @@
<?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\Model;
use DateTimeInterface;
class Day
{
public function __construct(private DateTimeInterface $day)
{
}
public function getDay(): DateTimeInterface
{
return $this->day;
}
}

54
src/Model/Month.php Normal file
View File

@@ -0,0 +1,54 @@
<?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\Model;
use DateTimeInterface;
class Month
{
/**
* @var Day[]
*/
private array $days = [];
public function __construct(private \DateTimeInterface $month)
{
$date = new \DateTimeImmutable($this->month->format('Y-m-01 00:00:00'));
$start = $date->format('m');
while ($start === $date->format('m')) {
$day = $this->createDay($date);
$this->setDay($day);
$date = $date->add(new \DateInterval('P1D'));
}
}
protected function createDay(\DateTimeInterface $day): Day
{
return new Day($day);
}
public function getMonth(): DateTimeInterface
{
return $this->month;
}
protected function setDay(Day $day): void
{
$this->days['_' . $day->getDay()->format('d')] = $day;
}
/**
* @return Day[]
*/
public function getDays(): array
{
return array_values($this->days);
}
}

60
src/Model/Year.php Normal file
View File

@@ -0,0 +1,60 @@
<?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\Model;
use DateTimeInterface;
class Year
{
/**
* @var Month[]
*/
private array $months = [];
public function __construct(private DateTimeInterface $month)
{
$monthDate = new \DateTimeImmutable();
$monthDate = $monthDate->setDate((int) $this->month->format('Y'), 1, 1);
$monthDate = $monthDate->setTime(1, 0);
for ($i = 1; $i < 13; $i++) {
$month = $this->createMonth($monthDate);
$this->setMonth($month);
$monthDate = $monthDate->add(new \DateInterval('P1M'));
}
}
protected function createMonth(\DateTimeInterface $month): Month
{
return new Month($month);
}
public function getYear(): DateTimeInterface
{
return $this->month;
}
protected function setMonth(Month $month): void
{
$this->months['_' . $month->getMonth()->format('m')] = $month;
}
public function getMonth(\DateTimeInterface $month): Month
{
return $this->months['_' . $month->format('m')];
}
/**
* @return Month[]
*/
public function getMonths(): array
{
return array_values($this->months);
}
}

View File

@@ -0,0 +1,89 @@
<?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\Repository;
use App\Entity\User;
use App\Entity\WorkingTime;
use Doctrine\ORM\EntityRepository;
/**
* @extends EntityRepository<WorkingTime>
* @internal use WorkingTimeService instead!
*/
class WorkingTimeRepository extends EntityRepository
{
public function deleteWorkingTime(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();
}
public function scheduleWorkingTimeUpdate(WorkingTime $workingTime): void
{
$this->getEntityManager()->persist($workingTime);
}
public function persistScheduledWorkingTimes(): void
{
$this->getEntityManager()->flush();
}
/**
* @return array<WorkingTime>
*/
public function findForYear(User $user, \DateTimeInterface $year): array
{
$qb = $this->createQueryBuilder('w');
$qb->select('w')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->eq('YEAR(w.date)', ':date'))
->setParameter('date', $year->format('Y'))
->indexBy('w', 'w.date')
->orderBy('w.date')
;
return $qb->getQuery()->getResult();
}
public function getLatestApproval(User $user): ?WorkingTime
{
$qb = $this->createQueryBuilder('w');
$qb->select('MAX(DATE(w.date))')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->isNotNull('w.approvedAt'))
;
$date = $qb->getQuery()->getSingleScalarResult();
if ($date === null) {
return null;
}
$qb = $this->createQueryBuilder('w');
$qb->select('w')
->where($qb->expr()->eq('w.user', ':user'))
->setParameter('user', $user->getId())
->andWhere($qb->expr()->eq('DATE(w.date)', 'DATE(:date)'))
->setParameter('date', $date)
;
return $qb->getQuery()->getOneOrNullResult(); // @phpstan-ignore-line
}
}

View File

@@ -33,6 +33,7 @@ final class UserVoter extends Voter
'api-token',
'hourly-rate',
'view_team_member',
'contract',
];
public function __construct(private RolePermissionManager $permissionManager)
@@ -60,6 +61,10 @@ final class UserVoter extends Voter
return false;
}
if ($attribute === 'contract') {
return $this->permissionManager->hasRolePermission($user, 'contract_other_profile');
}
if ($attribute === 'view_team_member') {
if ($subject->getId() !== $user->getId()) {
return false;

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\WorkingTime\Model;
final class BoxConfiguration
{
private bool $decimal = false;
private bool $collapsed = false;
public function setDecimal(bool $decimal): void
{
$this->decimal = $decimal;
}
public function setCollapsed(bool $collapsed): void
{
$this->collapsed = $collapsed;
}
public function isDecimal(): bool
{
return $this->decimal;
}
public function isCollapsed(): bool
{
return $this->collapsed;
}
}

View File

@@ -0,0 +1,28 @@
<?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\WorkingTime\Model;
use App\Entity\WorkingTime;
use App\Model\Day as BaseDay;
final class Day extends BaseDay
{
private ?WorkingTime $workingTime = null;
public function getWorkingTime(): ?WorkingTime
{
return $this->workingTime;
}
public function setWorkingTime(?WorkingTime $workingTime): void
{
$this->workingTime = $workingTime;
}
}

View File

@@ -0,0 +1,97 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\Model\Month as BaseMonth;
/**
* @method array<Day> getDays()
*/
final class Month extends BaseMonth
{
private ?bool $locked = null;
/**
* A month is only locked IF every day is approved.
* If there is even one day left open, the entire month is not locked.
*
* @return bool
*/
public function isLocked(): bool
{
if ($this->locked === null) {
$this->locked = true;
foreach ($this->getDays() as $day) {
if ($day->getWorkingTime() !== null && !$day->getWorkingTime()->isApproved()) {
$this->locked = false;
}
}
}
return $this->locked;
}
public function getLockDate(): ?\DateTimeInterface
{
foreach ($this->getDays() as $day) {
if ($day->getWorkingTime() !== null && $day->getWorkingTime()->isApproved()) {
return $day->getWorkingTime()->getApprovedAt();
}
}
return null;
}
public function getLockedBy(): ?User
{
foreach ($this->getDays() as $day) {
if ($day->getWorkingTime() !== null && $day->getWorkingTime()->isApproved()) {
return $day->getWorkingTime()->getApprovedBy();
}
}
return null;
}
protected function createDay(\DateTimeInterface $day): Day
{
return new Day($day);
}
public function getExpectedTime(\DateTimeInterface $until): int
{
$time = 0;
foreach ($this->getDays() as $day) {
if ($until < $day->getDay()) {
break;
}
if ($day->getWorkingTime() !== null) {
$time += $day->getWorkingTime()->getExpectedTime();
}
}
return $time;
}
public function getActualTime(): int
{
$time = 0;
foreach ($this->getDays() as $day) {
if ($day->getWorkingTime() !== null) {
$time += $day->getWorkingTime()->getActualTime();
}
}
return $time;
}
}

View File

@@ -0,0 +1,38 @@
<?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\WorkingTime\Model;
use App\Model\Month as BaseMonth;
final class MonthSummary extends BaseMonth
{
private int $expectedTime = 0;
private int $actualTime = 0;
public function getExpectedTime(): int
{
return $this->expectedTime;
}
public function setExpectedTime(int $expectedTime): void
{
$this->expectedTime = $expectedTime;
}
public function getActualTime(): int
{
return $this->actualTime;
}
public function setActualTime(int $actualTime): void
{
$this->actualTime = $actualTime;
}
}

View File

@@ -0,0 +1,57 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\Model\Year as BaseYear;
/**
* @method array<Month> getMonths()
* @method Month getMonth(\DateTimeInterface $month)
*/
final class Year extends BaseYear
{
public function __construct(\DateTimeInterface $month, private User $user)
{
parent::__construct($month);
}
public function getUser(): User
{
return $this->user;
}
protected function createMonth(\DateTimeInterface $month): Month
{
return new Month($month);
}
public function getExpectedTime(\DateTimeInterface $until): int
{
$time = 0;
foreach ($this->getMonths() as $month) {
$time += $month->getExpectedTime($until);
}
return $time;
}
public function getActualTime(): int
{
$time = 0;
foreach ($this->getMonths() as $month) {
$time += $month->getActualTime();
}
return $time;
}
}

View File

@@ -0,0 +1,78 @@
<?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\WorkingTime\Model;
use App\Entity\User;
/**
* @implements \IteratorAggregate<int, YearSummary>
*/
final class YearPerUserSummary implements \Countable, \IteratorAggregate
{
/** @var array<YearSummary> */
private array $summaries = [];
public function __construct(private Year $year)
{
}
public function getYear(): Year
{
return $this->year;
}
public function getUser(): User
{
return $this->year->getUser();
}
public function count(): int
{
return \count($this->summaries);
}
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->summaries);
}
/**
* @return YearSummary[]
*/
public function getSummaries(): array
{
return $this->summaries;
}
public function addSummary(YearSummary $summary): void
{
$this->summaries[] = $summary;
}
public function getExpectedTime(): int
{
$all = 0;
foreach ($this->getSummaries() as $month) {
$all += $month->getExpectedTime();
}
return $all;
}
public function getActualTime(): int
{
$all = 0;
foreach ($this->getSummaries() as $month) {
$all += $month->getActualTime();
}
return $all;
}
}

View File

@@ -0,0 +1,54 @@
<?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\WorkingTime\Model;
use App\Model\Year as BaseYear;
/**
* @method array<MonthSummary> getMonths()
* @method MonthSummary getMonth(\DateTimeInterface $month)
*/
final class YearSummary extends BaseYear
{
public function __construct(\DateTimeInterface $month, private string $title)
{
parent::__construct($month);
}
protected function createMonth(\DateTimeInterface $month): MonthSummary
{
return new MonthSummary($month);
}
public function getTitle(): string
{
return $this->title;
}
public function getExpectedTime(): int
{
$all = 0;
foreach ($this->getMonths() as $month) {
$all += $month->getExpectedTime();
}
return $all;
}
public function getActualTime(): int
{
$all = 0;
foreach ($this->getMonths() as $month) {
$all += $month->getActualTime();
}
return $all;
}
}

View File

@@ -0,0 +1,156 @@
<?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\WorkingTime;
use App\Entity\User;
use App\Entity\WorkingTime;
use App\Event\WorkingTimeYearSummaryEvent;
use App\Repository\TimesheetRepository;
use App\Repository\WorkingTimeRepository;
use App\Timesheet\DateTimeFactory;
use App\WorkingTime\Model\Month;
use App\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* @internal this API and the entire namespace is instable: you should expect changes!
*/
final class WorkingTimeService
{
public function __construct(private TimesheetRepository $timesheetRepository, private WorkingTimeRepository $workingTimeRepository, private EventDispatcherInterface $eventDispatcher)
{
}
public function getYearSummary(Year $year, \DateTimeInterface $until): YearPerUserSummary
{
$yearPerUserSummary = new YearPerUserSummary($year);
$summaryEvent = new WorkingTimeYearSummaryEvent($yearPerUserSummary, $until);
$this->eventDispatcher->dispatch($summaryEvent);
return $yearPerUserSummary;
}
public function getLatestApproval(User $user): ?WorkingTime
{
return $this->workingTimeRepository->getLatestApproval($user);
}
public function getYear(User $user, \DateTimeInterface $yearDate): Year
{
$yearTimes = $this->workingTimeRepository->findForYear($user, $yearDate);
$existing = [];
foreach ($yearTimes as $workingTime) {
$existing[$workingTime->getDate()->format('Y-m-d')] = $workingTime;
}
$year = new Year(\DateTimeImmutable::createFromInterface($yearDate), $user);
$stats = null;
foreach ($year->getMonths() as $month) {
foreach ($month->getDays() as $day) {
$key = $day->getDay()->format('Y-m-d');
if (\array_key_exists($key, $existing)) {
$day->setWorkingTime($existing[$key]);
continue;
}
if ($stats === null) {
$stats = $this->getYearStatistics($yearDate, $user);
}
$result = new WorkingTime($user, $day->getDay());
$result->setExpectedTime($user->getWorkHoursForDay($day->getDay()));
if (\array_key_exists($key, $stats)) {
$result->setActualTime($stats[$key]);
}
$day->setWorkingTime($result);
}
}
return $year;
}
public function getMonth(User $user, \DateTimeInterface $monthDate): Month
{
// TODO improve me, do not calculate the entire year for that
$year = $this->getYear($user, $monthDate);
return $year->getMonth($monthDate);
}
public function approveMonth(Month $month, \DateTimeInterface $approvalDate, User $approver): void
{
$update = false;
foreach ($month->getDays() as $day) {
$workingTime = $day->getWorkingTime();
if ($workingTime === null) {
continue;
}
if ($workingTime->getId() !== null) {
continue;
}
if ($month->isLocked() || $workingTime->isApproved()) {
continue;
}
$workingTime->setApprovedBy($approver);
$workingTime->setApprovedAt($approvalDate);
$this->workingTimeRepository->scheduleWorkingTimeUpdate($workingTime);
$update = true;
}
if ($update) {
$this->workingTimeRepository->persistScheduledWorkingTimes();
}
}
/**
* @param \DateTimeInterface $year
* @param User $user
* @return array<string, int>
*/
private function getYearStatistics(\DateTimeInterface $year, User $user): array
{
$dateTimeFactory = DateTimeFactory::createByUser($user);
$begin = $dateTimeFactory->createStartOfYear($year);
$end = $dateTimeFactory->createEndOfYear($year);
$qb = $this->timesheetRepository->createQueryBuilder('t');
$qb
->select('COALESCE(SUM(t.duration), 0) as duration')
->addSelect('DATE(t.date) as day')
->where($qb->expr()->isNotNull('t.end'))
->andWhere($qb->expr()->between('t.begin', ':begin', ':end'))
->andWhere($qb->expr()->eq('t.user', ':user'))
->setParameter('begin', $begin)
->setParameter('end', $end)
->setParameter('user', $user->getId())
->addGroupBy('day')
;
$results = $qb->getQuery()->getResult();
$durations = [];
foreach ($results as $row) {
$durations[$row['day']] = (int) $row['duration'];
}
return $durations; // @phpstan-ignore-line
}
}