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

26
composer.lock generated
View File

@@ -1449,16 +1449,16 @@
},
{
"name": "doctrine/orm",
"version": "2.15.0",
"version": "2.15.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
"reference": "7b5fafffbeb560e75adc349e71bf1e90463796d2"
"reference": "9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/7b5fafffbeb560e75adc349e71bf1e90463796d2",
"reference": "7b5fafffbeb560e75adc349e71bf1e90463796d2",
"url": "https://api.github.com/repos/doctrine/orm/zipball/9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc",
"reference": "9bc6f5b4ac6f1e7d4248b2efbd01a748782075bc",
"shasum": ""
},
"require": {
@@ -1494,7 +1494,7 @@
"symfony/cache": "^4.4 || ^5.4 || ^6.0",
"symfony/var-exporter": "^4.4 || ^5.4 || ^6.2",
"symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0",
"vimeo/psalm": "4.30.0 || 5.9.0"
"vimeo/psalm": "4.30.0 || 5.11.0"
},
"suggest": {
"ext-dom": "Provides support for XSD validation for XML mapping files",
@@ -1544,9 +1544,9 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/2.15.0"
"source": "https://github.com/doctrine/orm/tree/2.15.1"
},
"time": "2023-04-26T18:52:02+00:00"
"time": "2023-05-07T18:56:25+00:00"
},
{
"name": "doctrine/persistence",
@@ -12153,16 +12153,16 @@
},
{
"name": "sebastian/diff",
"version": "4.0.4",
"version": "4.0.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/diff.git",
"reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
"reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
"reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
"reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131",
"shasum": ""
},
"require": {
@@ -12207,7 +12207,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/diff/issues",
"source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
"source": "https://github.com/sebastianbergmann/diff/tree/4.0.5"
},
"funding": [
{
@@ -12215,7 +12215,7 @@
"type": "github"
}
],
"time": "2020-10-26T13:10:38+00:00"
"time": "2023-05-07T05:35:17+00:00"
},
{
"name": "sebastian/environment",

View File

@@ -75,7 +75,7 @@ kimai:
# PERMISSIONS
# --------------------------------------------------------------------------------
permissions:
# mapping complex rule sets of single permissions to named "sets" ("set name" = [array of "permissions and @SETS"])
# mapping a "set name" to an array of "permission names"
sets:
ACTIVITIES: ['view_activity','create_activity','edit_activity','budget_activity','time_activity','delete_activity','permissions_activity']
ACTIVITIES_ALL_TEAMLEAD: ['view_teamlead_activity','edit_teamlead_activity','budget_teamlead_activity','time_teamlead_activity','permissions_teamlead_activity']
@@ -107,7 +107,7 @@ kimai:
REPORTING: ['view_reporting','view_other_reporting','project_reporting','customer_reporting']
# permissions which are deactivated, as these features are hidden for now
# brave users can try to activate them and be surprised what happens
REGISTER_BETA: []
REGISTER_BETA: ['contract_other_profile']
# mapping a "role name" to an array of "set names"
maps:
ROLE_USER: ['TIMESHEET','PROFILE']
@@ -118,7 +118,9 @@ kimai:
roles:
ROLE_USER: ['view_team_member','time_team_project','create_tag','view_reporting']
ROLE_TEAMLEAD: ['view_rate_own_timesheet','view_rate_other_timesheet','hourly-rate_own_profile','view_team_member']
# TODO contract_other_profile
ROLE_ADMIN: ['hourly-rate_own_profile','edit_exported_timesheet','teams_own_profile','view_team_member','view_all_data']
# TODO contract_other_profile
ROLE_SUPER_ADMIN: ['hourly-rate_own_profile','hourly-rate_other_profile','roles_own_profile','system_information','system_configuration','plugins','edit_exported_timesheet','teams_own_profile','view_team_member','upload_invoice_template','view_all_data']
# --------------------------------------------------------------------------------
@@ -162,3 +164,7 @@ kimai:
when@test:
kimai:
data_dir: '%kernel.project_dir%/tests/_data'
permissions:
roles:
ROLE_ADMIN: ['contract_other_profile']
ROLE_SUPER_ADMIN: ['contract_other_profile']

View File

@@ -55,6 +55,7 @@ tabler:
columns: fas fa-table
comment: far fa-comment
configuration: fas fa-cog
contract: fas fa-balance-scale
copy: far fa-copy
create: fas fa-plus
csv: fas fa-table
@@ -109,6 +110,7 @@ tabler:
repeat: fas fa-redo-alt
reporting: far fa-chart-bar
report: far fa-chart-bar
review: fas fa-user-check
right: fas fa-chevron-right
right2: fas fa-angle-double-right
roles: fas fa-user-shield
@@ -136,4 +138,5 @@ tabler:
weekly-times: fas fa-th
widget_add: fas fa-folder-plus
widget_remove: fas fa-folder-minus
work_times: fas fa-calculator
xlsx: fas fa-file-excel

View File

@@ -224,3 +224,8 @@ services:
class: App\Repository\BookmarkRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Bookmark']
App\Repository\WorkingTimeRepository:
class: App\Repository\WorkingTimeRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\WorkingTime']

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
/**
* @version 2.1
*/
final class Version20230327143628 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates the working times table';
}
public function up(Schema $schema): void
{
$workingTimes = $schema->createTable('kimai2_working_times');
$workingTimes->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$workingTimes->addColumn('user_id', 'integer', ['notnull' => true]);
$workingTimes->addColumn('approved_by', 'integer', ['notnull' => false, 'default' => null]);
$workingTimes->addColumn('date', 'date', ['notnull' => true]);
$workingTimes->addColumn('expected', 'integer', ['notnull' => true]);
$workingTimes->addColumn('actual', 'integer', ['notnull' => true]);
$workingTimes->addColumn('approved_at', 'datetime', ['notnull' => false, 'default' => null]);
$workingTimes->setPrimaryKey(['id']);
$workingTimes->addIndex(['user_id'], 'IDX_F95E4933A76ED395');
$workingTimes->addIndex(['approved_by'], 'IDX_F95E49334EA3CB3D');
$workingTimes->addUniqueIndex(['user_id', 'date'], 'UNIQ_F95E4933A76ED395AA9E377A');
$workingTimes->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_F95E4933A76ED395');
$workingTimes->addForeignKeyConstraint('kimai2_users', ['approved_by'], ['id'], ['onDelete' => 'SET NULL'], 'FK_F95E49334EA3CB3D');
}
public function down(Schema $schema): void
{
$workingTimes = $schema->getTable('kimai2_working_times');
$workingTimes->removeForeignKey('FK_F95E4933A76ED395');
$workingTimes->removeForeignKey('FK_F95E49334EA3CB3D');
$schema->dropTable('kimai2_working_times');
}
public function isTransactional(): bool
{
return false;
}
}

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
}
}

View File

@@ -0,0 +1,311 @@
{% extends 'base.html.twig' %}
{% import "macros/widgets.html.twig" as widgets %}
{% block main %}
{% set withWorkHourConfiguration = user.hasWorkHourConfiguration() %}
{% if form is defined %}
<div class="card mb-3">
<div class="card-body">
{{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }}
<div class="btn-list w-100">
{{ form_widget(form.date) }}
{% if form.user is defined %}
{{ form_widget(form.user, {'label': false}) }}
{% elseif app.user != user %}
{{ widgets.username(user) }}
{% endif %}
</div>
{{ form_end(form) }}
</div>
</div>
{% endif %}
{% if not withWorkHourConfiguration %}
{{ widgets.alert('info', 'work_times_should.none_configured'|trans) }}
{% endif %}
{% if summaries|length > 0 %}
{% embed '@theme/embeds/card.html.twig' with {'fullsize': true} %}
{% from "macros/status.html.twig" import status_duration %}
{% from "macros/widgets.html.twig" import work_times_result %}
{% block box_title %}
{{ 'export.summary'|trans }}
{{ status_duration(work_times_result(summaries.expectedTime, summaries.actualTime, decimal)) }}
{% endblock %}
{% block box_body_classes %}card-table table-responsive{% endblock %}
{% block box_body %}
<table class="table table-vcenter table-hover dataTable">
<thead>
<tr>
<th></th>
<th class="text-end total">{{ 'sum.total'|trans }}</th>
{% for month in year.months %}
<th class="text-end">{{ month.month|month_name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% set monthTotalShould = {'_01': 0, '_02': 0, '_03': 0, '_04': 0, '_05': 0, '_06': 0, '_07': 0, '_08': 0, '_09': 0, '_10': 0, '_11': 0, '_12': 0} %}
{% set monthTotalIs = {'_01': 0, '_02': 0, '_03': 0, '_04': 0, '_05': 0, '_06': 0, '_07': 0, '_08': 0, '_09': 0, '_10': 0, '_11': 0, '_12': 0} %}
{% for summary in summaries %}
<tr>
<td>{{ summary.title|trans }}</td>
<td class="text-end total">
{% if summary.expectedTime > 0 %}
{{ work_times_result(summary.expectedTime, summary.actualTime, decimal) }}
{% else %}
{{ summary.actualTime|duration(decimal) }}
{% endif %}
</td>
{% for month in summary.months %}
{% set monthShould = month.expectedTime %}
{% set monthIs = month.actualTime %}
{% set dateKey = '_' ~ month.month.format('m') %}
{% set monthTotalShouldTmp = monthTotalShould[dateKey] + monthShould %}
{% set monthTotalIsTmp = monthTotalIs[dateKey] + monthIs %}
{% set monthTotalShould = monthTotalShould|merge({(dateKey): monthTotalShouldTmp}) %}
{% set monthTotalIs = monthTotalIs|merge({(dateKey): monthTotalIsTmp}) %}
<td class="text-end">
{% if now < month.month and monthShould > 0 %}
{{ monthShould|duration(decimal) }}
{% else %}
{{ work_times_result(monthShould, monthIs, decimal) }}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="summary">
<td></td>
<td class="text-end total">
{{ work_times_result(summaries.expectedTime, summaries.actualTime, decimal) }}
</td>
{% for month in year.months %}
{% set dateKey = '_' ~ month.month.format('m') %}
<td class="text-end">
{{ work_times_result(monthTotalShould[dateKey], monthTotalIs[dateKey], decimal) }}
</td>
{% endfor %}
</tr>
</tfoot>
</table>
{% endblock %}
{% endembed %}
{% endif %}
{% if withWorkHourConfiguration %}
{% set expectedWeekTimes = user.workHoursMonday + user.workHoursTuesday + user.workHoursWednesday + user.workHoursThursday + user.workHoursFriday + user.workHoursSaturday + user.workHoursSunday %}
{% embed '@theme/embeds/card.html.twig' with {'collapsed': box_configuration.collapsed} %}
{% from "macros/status.html.twig" import status_duration %}
{% block box_title %}
{{ 'work_times_should'|trans }}
{{ status_duration(expectedWeekTimes|duration) }}
{% endblock %}
{% block box_body %}
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">{{ 'agendaWeek'|trans }}</div>
<div class="datagrid-content">{{ expectedWeekTimes|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Monday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursMonday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Tuesday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursTuesday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Wednesday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursWednesday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Thursday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursThursday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Friday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursFriday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Saturday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursSaturday|duration }}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">{{ 'Sunday'|trans({}, 'system-configuration') }}</div>
<div class="datagrid-content">{{ user.workHoursSunday|duration }}</div>
</div>
</div>
{% endblock %}
{% endembed %}
{% endif %}
{% if year is defined %}
{% embed '@theme/embeds/card.html.twig' with {'collapsed': box_configuration.collapsed, 'id': 'working_time_detail_box', 'fullsize': true} %}
{% set yearShould = 0 %}
{% set yearIs = 0 %}
{% set approved = 0 %}
{% 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 %}
{% endif %}
{% endfor %}
{% from "macros/widgets.html.twig" import work_times_result, table_actions, card_tool_button, username %}
{% from "macros/status.html.twig" import status_count %}
{% block box_title %}
{{ 'work_times'|trans }}
{% if withWorkHourConfiguration %}
{{ status_count(approved ~ '/' ~ year.months|length) }}
{% endif %}
{% endblock %}
{% block box_body_classes %}card-table table-responsive{% endblock %}
{% block box_tools %}
{# {{ card_tool_button('visibility', {'onclick': "toggleDays(); return false;", 'id': 'toggleDaysIcon'}) }} #}
{% endblock %}
{% block box_body %}
<table id="working_times_details" class="table table-vcenter table-hover dataTable">
<thead>
<tr>
{% if withWorkHourConfiguration %}
<th></th>
{% endif %}
<th>{{ 'month'|trans }}</th>
<th class="text-end total">{{ 'sum.total'|trans }}</th>
<th class="text-end">{{ 'work_times_should'|trans }}</th>
<th class="text-end">{{ 'work_times_is'|trans }}</th>
{% for day in 1..31 %}
<th class="text-end contractDay">{% if day < 10 %}0{% endif %}{{ day }}</th>
{% endfor %}
<th class="actions"></th>
</tr>
</thead>
<tbody>
{% 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">
{% if month.locked %}
<i class="{{ 'locked'|icon }}" data-toggle="tooltip" title="{% if month.lockedBy is not null %}{{ month.lockedBy.displayName }}{% else %}{{ 'deleted'|trans }}{% endif %}: {{ month.lockDate|date_short }}"></i>
{% else %}
<i class="bg-yellow-lt {{ 'unlocked'|icon }}"></i>
{% endif %}
</td>
{% endif %}
<td class="text-nowrap w-min">
{{ 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) }}
</td>
<td class="text-end w-min">
{{ monthIs|duration(decimal) }}
</td>
{% set dayCount = 0 %}
{% for day in month.days %}
{% set class = 'text-end contractDay' %}
{% if day.day is weekend %}
{% set class = class ~ ' weekend' %}
{% endif %}
{% if day.workingTime.expectedTime > 0 and day.workingTime.actualTime == 0 and now > day.day %}
{% set class = class ~ ' bg-orange-lt' %}
{% elseif day.workingTime.expectedTime == 0 and day.workingTime.actualTime > 0 and now > day.day %}
{% set class = class ~ ' bg-yellow-lt' %}
{% endif %}
<td class="{{ class }}">
{% if (day.workingTime.expectedTime != 0 or day.workingTime.actualTime != 0) and (now > day.day or day.workingTime.actualTime > 0) %}
{{ work_times_result(day.workingTime.expectedTime, day.workingTime.actualTime, decimal) }}
{% endif %}
</td>
{% set dayCount = dayCount + 1 %}
{% endfor %}
{% if dayCount < 31 %}
{% for day in 1..(31-dayCount)%}
<td class="contractDay"></td>
{% endfor %}
{% endif %}
<td class="actions">
{% set event = actions(app.user, 'contract_month', 'index', {'year': year, 'month': month}) %}
{{ table_actions(event.actions) }}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="summary">
{% if withWorkHourConfiguration %}
<td></td>
{% endif %}
<td></td>
<td class="text-end total">
{{ work_times_result(yearShould, yearIs, decimal) }}
</td>
<td class="text-end">
{{ yearShould|duration(decimal) }}
</td>
<td class="text-end">
{{ yearIs|duration(decimal) }}
</td>
{% for day in 1..31 %}
<td class="contractDay"></td>
{% endfor %}
<td></td>
</tr>
</tfoot>
</table>
{% endblock %}
{% endembed %}
{% endif %}
{% for controller in boxes %}
{{ render(controller(controller, {'year': year, 'boxConfiguration': box_configuration})) }}
{% endfor %}
{% endblock %}
{% block javascripts %}
{{ parent() }}
<script type="text/javascript">
function toggleDays()
{
document.querySelectorAll('table#working_times_details .contractDay').forEach(
element => {
element.classList.toggle('d-none');
}
);
const icon = document.querySelector('#toggleDaysIcon i');
icon.classList.toggle('fa-eye');
icon.classList.toggle('fa-eye-slash');
}
document.addEventListener('kimai.initialized', function() {
KimaiReloadPageWidget.create('kimai.workingTimesUpdate', true);
const contractForm = document.getElementById('report-form');
if (contractForm !== null) {
contractForm.addEventListener('change', () => {
contractForm.submit();
});
}
});
</script>
{% endblock %}

View File

@@ -514,4 +514,17 @@
KimaiReloadPageWidget.create('{{ events }}', {% if full_reload is same as true %}true{% else %}false{% endif %});
});
</script>
{% endmacro %}
{% endmacro %}
{% macro work_times_result(should, actual, decimal) %}
{% set result = actual - should %}
{% if result == 0 %}
<span class="work_time_exact">{{ result|duration(decimal) }}</span>
{% elseif should == 0 and actual > 0 %}
<span class="work_time_positive text-green">+{{ actual|duration(decimal) }}</span>
{% elseif result < 0 %}
<span class="work_time_negative text-red">{{ result|duration(decimal) }}</span>
{% else %}
<span class="work_time_positive text-green">+{{ result|duration(decimal) }}</span>
{% endif %}
{% endmacro %}

View File

@@ -0,0 +1,41 @@
{% extends 'user/form.html.twig' %}
{% block form_content %}
<h3>{{ 'work_times_should'|trans }}</h3>
<table class="table">
<thead>
<tr>
<th>{{ 'Monday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Tuesday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Wednesday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Thursday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Friday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Saturday'|trans({}, 'system-configuration') }}</th>
<th>{{ 'Sunday'|trans({}, 'system-configuration') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ form_widget(form.workHoursMonday) }}</td>
<td>{{ form_widget(form.workHoursTuesday) }}</td>
<td>{{ form_widget(form.workHoursWednesday) }}</td>
<td>{{ form_widget(form.workHoursThursday) }}</td>
<td>{{ form_widget(form.workHoursFriday) }}</td>
<td>{{ form_widget(form.workHoursSaturday) }}</td>
<td>{{ form_widget(form.workHoursSunday) }}</td>
</tr>
</tbody>
</table>
<p>{{ 'work_times_should.help'|trans }}</p>
{% if form.holidaysPerYear is defined %}
<h3>{{ 'pto_year_holiday'|trans }}</h3>
{{ form_rest(form) }}
{% endif %}
{% endblock %}

View File

@@ -17,7 +17,7 @@
</div>
{% block form_body %}
{{ form_start(form) }}
<div class="card-body">
<div class="card-body {% block form_body_class %}{% endblock %}">
{% block form_pre_content %}{% endblock %}
{% block form_content %}
{{ form_widget(form) }}

View File

@@ -0,0 +1,45 @@
<?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\Controller;
use App\Entity\User;
/**
* @group integration
*/
class ContractControllerTest extends ControllerBaseTest
{
public function testIsSecure(): void
{
$this->assertUrlIsSecured('/contract');
}
public function testIndexAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/contract');
$content = $client->getResponse()->getContent();
self::assertNotFalse($content);
self::assertStringContainsString('No target hours have been configured', $content);
$node = $client->getCrawler()->filter('select#user');
self::assertEquals(0, $node->count());
}
public function testAdminCanChangeUser(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertAccessIsGranted($client, '/contract');
$content = $client->getResponse()->getContent();
self::assertNotFalse($content);
self::assertStringContainsString('No target hours have been configured', $content);
$node = $client->getCrawler()->filter('select#user');
self::assertEquals(1, $node->count());
}
}

View File

@@ -34,7 +34,7 @@ class PermissionControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->assertAccessIsGranted($client, '/admin/permissions');
$this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 129);
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 130);
$this->assertPageActions($client, [
'create modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
]);

View File

@@ -541,4 +541,56 @@ class ProfileControllerTest extends ControllerBaseTest
{
$this->assertUrlIsSecured('/profile/' . UserFixtures::USERNAME_USER . '/2fa_deactivate', 'POST');
}
public function testContractActionIsSecured(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/contract');
$this->assertFalse($client->getResponse()->isSuccessful());
}
public function testContractAction(): void
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
$this->request($client, '/profile/' . UserFixtures::USERNAME_USER . '/contract');
$this->assertTrue($client->getResponse()->isSuccessful());
$user = $this->getUserByRole(User::ROLE_USER);
$this->assertEquals(0, $user->getWorkHoursMonday());
$this->assertEquals(0, $user->getWorkHoursTuesday());
$this->assertEquals(0, $user->getWorkHoursWednesday());
$this->assertEquals(0, $user->getWorkHoursThursday());
$this->assertEquals(0, $user->getWorkHoursFriday());
$this->assertEquals(0, $user->getWorkHoursSaturday());
$this->assertEquals(0, $user->getWorkHoursSunday());
$form = $client->getCrawler()->filter('form[name=user_contract]')->form();
$client->submit($form, [
'user_contract[workHoursMonday]' => '1:00',
'user_contract[workHoursTuesday]' => '2:00',
'user_contract[workHoursWednesday]' => '3:00',
'user_contract[workHoursThursday]' => '4:30',
'user_contract[workHoursFriday]' => '5:12',
'user_contract[workHoursSaturday]' => '6:59',
'user_contract[workHoursSunday]' => '0:01',
]);
$this->assertIsRedirect($client, $this->createUrl('/profile/' . urlencode(UserFixtures::USERNAME_USER) . '/contract'));
$client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client);
$user = $this->getUserByRole(User::ROLE_USER);
$this->assertEquals(3600, $user->getWorkHoursMonday());
$this->assertEquals(7200, $user->getWorkHoursTuesday());
$this->assertEquals(10800, $user->getWorkHoursWednesday());
$this->assertEquals(16200, $user->getWorkHoursThursday());
$this->assertEquals(18720, $user->getWorkHoursFriday());
$this->assertEquals(25140, $user->getWorkHoursSaturday());
$this->assertEquals(60, $user->getWorkHoursSunday());
}
}

View File

@@ -67,9 +67,52 @@ class UserTest extends TestCase
$user->setAccountNumber('A-058375');
self::assertEquals('A-058375', $user->getAccountNumber());
self::assertEquals(0, $user->getWorkHoursMonday());
self::assertEquals(0, $user->getWorkHoursTuesday());
self::assertEquals(0, $user->getWorkHoursWednesday());
self::assertEquals(0, $user->getWorkHoursThursday());
self::assertEquals(0, $user->getWorkHoursFriday());
self::assertEquals(0, $user->getWorkHoursSaturday());
self::assertEquals(0, $user->getWorkHoursSunday());
self::assertEquals(0, $user->getHolidaysPerYear());
self::assertFalse($user->hasWorkHourConfiguration());
}
public function testColor()
public function testWorkContract(): void
{
$user = new User();
$user->setWorkHoursMonday(7200);
self::assertTrue($user->hasWorkHourConfiguration());
$user->setWorkHoursTuesday(7300);
$user->setWorkHoursWednesday(7400);
$user->setWorkHoursThursday(7500);
$user->setWorkHoursFriday(7600);
$user->setWorkHoursSaturday(7700);
$user->setWorkHoursSunday(7800);
$user->setHolidaysPerYear(10);
self::assertTrue($user->hasWorkHourConfiguration());
self::assertEquals(7200, $user->getWorkHoursMonday());
self::assertEquals(7300, $user->getWorkHoursTuesday());
self::assertEquals(7400, $user->getWorkHoursWednesday());
self::assertEquals(7500, $user->getWorkHoursThursday());
self::assertEquals(7600, $user->getWorkHoursFriday());
self::assertEquals(7700, $user->getWorkHoursSaturday());
self::assertEquals(7800, $user->getWorkHoursSunday());
self::assertEquals(10, $user->getHolidaysPerYear());
self::assertEquals(7200, $user->getWorkHoursForDay(new \DateTime('2023-05-08 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7300, $user->getWorkHoursForDay(new \DateTime('2023-05-09 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7400, $user->getWorkHoursForDay(new \DateTime('2023-05-10 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7500, $user->getWorkHoursForDay(new \DateTime('2023-05-11 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7600, $user->getWorkHoursForDay(new \DateTime('2023-05-12 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7700, $user->getWorkHoursForDay(new \DateTime('2023-05-13 12:00:00', new \DateTimeZone('Europe/Berlin'))));
self::assertEquals(7800, $user->getWorkHoursForDay(new \DateTime('2023-05-14 12:00:00', new \DateTimeZone('Europe/Berlin'))));
}
public function testColor(): void
{
$sut = new User();
self::assertNull($sut->getColor());

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\Tests\Entity;
use App\Entity\User;
use App\Entity\WorkingTime;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\WorkingTime
*/
class WorkingTimeTest extends TestCase
{
public function testDefaultValues(): void
{
$user = new User();
$user->setUsername('bar');
$date = new \DateTime();
$sut = new WorkingTime($user, $date);
self::assertSame($user, $sut->getUser());
self::assertSame($date, $sut->getDate());
self::assertNull($sut->getId());
self::assertEquals(0, $sut->getActualTime());
self::assertEquals(0, $sut->getExpectedTime());
self::assertNull($sut->getApprovedAt());
self::assertNull($sut->getApprovedBy());
self::assertFalse($sut->isApproved());
$approvedAt = new \DateTime('2023-01-01 12:00:00', new \DateTimeZone('Europe/Vienna'));
$approvedBy = new User();
$approvedBy->setUsername('foo');
$sut->setApprovedAt($approvedAt);
$sut->setApprovedBy($approvedBy);
$sut->setActualTime(5600);
$sut->setExpectedTime(8600);
self::assertSame(5600, $sut->getActualTime());
self::assertSame(8600, $sut->getExpectedTime());
self::assertSame($approvedAt, $sut->getApprovedAt());
self::assertSame($approvedBy, $sut->getApprovedBy());
self::assertTrue($sut->isApproved());
}
}

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\Tests\Event;
use App\Entity\User;
use App\Event\WorkContractDetailControllerEvent;
use App\WorkingTime\Model\Year;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\WorkContractDetailControllerEvent
*/
class WorkContractDetailControllerEventTest extends TestCase
{
public function testGetter(): void
{
$user = new User();
$date = new \DateTime('2023-02-10');
$year = new Year($date, $user);
$sut = new WorkContractDetailControllerEvent($year);
self::assertSame($year, $sut->getYear());
self::assertEquals([], $sut->getController());
$sut->addController('foo');
$sut->addController('bar');
self::assertEquals(['foo', 'bar'], $sut->getController());
}
}

View File

@@ -0,0 +1,43 @@
<?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\WorkingTimeYearSummaryEvent;
use App\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary;
use App\WorkingTime\Model\YearSummary;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\WorkingTimeYearSummaryEvent
*/
class WorkingTimeYearSummaryEventTest extends TestCase
{
public function testGetter(): void
{
$user = new User();
$date = new \DateTime('2023-02-10');
$year = new Year($date, $user);
$until = new \DateTimeImmutable();
$yearPerUser = new YearPerUserSummary($year);
$sut = new WorkingTimeYearSummaryEvent($yearPerUser, $until);
self::assertSame($year, $sut->getYear());
self::assertSame($until, $sut->getUntil());
$month = new \DateTime('2023-04-10');
$holiday = new YearSummary($month, 'holiday');
$sut->addSummary($holiday);
$sickness = new YearSummary($month, 'sickness');
$sut->addSummary($sickness);
self::assertEquals([$holiday, $sickness], $yearPerUser->getSummaries());
}
}

26
tests/Model/DayTest.php Normal file
View File

@@ -0,0 +1,26 @@
<?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\Model;
use App\Model\Day;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Day
*/
class DayTest extends TestCase
{
public function testDefaults(): void
{
$date = new \DateTimeImmutable('2023-07-21');
$sut = new Day($date);
self::assertSame($date, $sut->getDay());
}
}

32
tests/Model/MonthTest.php Normal file
View File

@@ -0,0 +1,32 @@
<?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\Model;
use App\Model\Month;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Month
*/
class MonthTest extends TestCase
{
public function testDefaults(): void
{
$months = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
foreach($months as $key => $days) {
$index = ++$key;
$monthKey = ($index < 10) ? '0' . $index : $index;
$date = new \DateTimeImmutable(sprintf('2020-%s-25 13:00:00', $monthKey));
$month = new Month($date);
self::assertEquals(sprintf('2020-%s-25', $monthKey), $month->getMonth()->format('Y-m-d'));
self::assertCount($days, $month->getDays());
}
}
}

35
tests/Model/YearTest.php Normal file
View File

@@ -0,0 +1,35 @@
<?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\Model;
use App\Model\Year;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Year
*/
class YearTest extends TestCase
{
public function testDefaults(): void
{
$date = new \DateTimeImmutable('2020-03-14 13:00:00');
$sut = new Year($date);
self::assertCount(12, $sut->getMonths());
self::assertSame($date, $sut->getYear());
$months = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
foreach($months as $key => $days) {
$index = ++$key;
$monthKey = ($index < 10) ? '0' . $index : $index;
$month = $sut->getMonth(new \DateTimeImmutable(sprintf('2020-%s-13 13:00:00', $monthKey)));
self::assertEquals(sprintf('2020-%s-01', $monthKey), $month->getMonth()->format('Y-m-d'));
self::assertCount($days, $month->getDays());
}
}
}

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\Tests\WorkingTime\Model;
use App\WorkingTime\Model\BoxConfiguration;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\BoxConfiguration
*/
class BoxConfigurationTest extends TestCase
{
public function testDefaults(): void
{
$sut = new BoxConfiguration();
self::assertFalse($sut->isDecimal());
self::assertFalse($sut->isCollapsed());
}
public function testSetter(): void
{
$sut = new BoxConfiguration();
$sut->setDecimal(true);
self::assertTrue($sut->isDecimal());
self::assertFalse($sut->isCollapsed());
$sut->setCollapsed(true);
self::assertTrue($sut->isDecimal());
self::assertTrue($sut->isCollapsed());
}
}

View File

@@ -0,0 +1,35 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\Entity\WorkingTime;
use App\WorkingTime\Model\Day;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\Day
*/
class DayTest extends TestCase
{
public function testDefaults(): void
{
$sut = new Day(new \DateTimeImmutable());
self::assertNull($sut->getWorkingTime());
}
public function testSetter(): void
{
$sut = new Day(new \DateTimeImmutable());
$workingTime = new WorkingTime(new User(), new \DateTimeImmutable());
$sut->setWorkingTime($workingTime);
self::assertSame($workingTime, $sut->getWorkingTime());
}
}

View File

@@ -0,0 +1,35 @@
<?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\WorkingTime\Model;
use App\WorkingTime\Model\MonthSummary;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\MonthSummary
*/
class MonthSummaryTest extends TestCase
{
public function testDefaults(): void
{
$sut = new MonthSummary(new \DateTimeImmutable());
self::assertEquals(0, $sut->getExpectedTime());
self::assertEquals(0, $sut->getActualTime());
}
public function testSetter(): void
{
$sut = new MonthSummary(new \DateTimeImmutable());
$sut->setExpectedTime(98765);
$sut->setActualTime(123456);
self::assertEquals(98765, $sut->getExpectedTime());
self::assertEquals(123456, $sut->getActualTime());
}
}

View File

@@ -0,0 +1,56 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\Entity\WorkingTime;
use App\WorkingTime\Model\Day;
use App\WorkingTime\Model\Month;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\Month
*/
class MonthTest extends TestCase
{
public function testDefaults(): void
{
$months = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
foreach($months as $key => $days) {
$index = ++$key;
$monthKey = ($index < 10) ? '0' . $index : $index;
$date = new \DateTimeImmutable(sprintf('2020-%s-25 13:00:00', $monthKey));
$month = new Month($date);
self::assertEquals(sprintf('2020-%s-25', $monthKey), $month->getMonth()->format('Y-m-d'));
self::assertCount($days, $month->getDays());
self::assertTrue($month->isLocked());
self::assertEquals(0, $month->getActualTime());
self::assertEquals(0, $month->getExpectedTime(new \DateTimeImmutable('2035-01-02')));
self::assertNull($month->getLockDate());
self::assertNull($month->getLockedBy());
foreach ($month->getDays() as $day) {
self::assertInstanceOf(Day::class, $day);
self::assertNull($day->getWorkingTime());
$duration = rand(0, 28000);
$expected = rand(0, 28000);
$workingTime = new WorkingTime(new User(), $day->getDay());
$workingTime->setExpectedTime($expected);
$workingTime->setActualTime($duration);
$day->setWorkingTime($workingTime);
self::assertSame($workingTime, $day->getWorkingTime());
self::assertEquals($expected, $day->getWorkingTime()->getExpectedTime());
self::assertEquals($duration, $day->getWorkingTime()->getActualTime());
}
}
}
}

View File

@@ -0,0 +1,65 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\WorkingTime\Model\Year;
use App\WorkingTime\Model\YearPerUserSummary;
use App\WorkingTime\Model\YearSummary;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\YearPerUserSummary
*/
class YearPerUserSummaryTest extends TestCase
{
public function testDefaults(): void
{
$monthDate = new \DateTimeImmutable();
$user = new User();
$user->setUserIdentifier('foooo');
$year = new Year($monthDate, $user);
$sut = new YearPerUserSummary($year);
self::assertSame($year, $sut->getYear());
self::assertSame($user, $sut->getUser());
self::assertEquals(0, $sut->getExpectedTime());
self::assertEquals(0, $sut->getActualTime());
self::assertEquals(0, $sut->count());
self::assertEquals([], $sut->getSummaries());
}
public function testWithSummary(): void
{
$monthDate = new \DateTimeImmutable();
$user = new User();
$user->setUserIdentifier('foooo');
$year = new Year($monthDate, $user);
$sut = new YearPerUserSummary($year);
$summary = new YearSummary($monthDate, 'Foo-Bar');
$monthSummary = $summary->getMonth(new \DateTimeImmutable('2020-04-27'));
$monthSummary->setActualTime(35679);
$monthSummary->setExpectedTime(135679);
$monthSummary = $summary->getMonth(new \DateTimeImmutable('2020-07-27'));
$monthSummary->setActualTime(95679);
$monthSummary->setExpectedTime(235679);
$sut->addSummary($summary);
self::assertEquals(131358, $sut->getActualTime());
self::assertEquals(371358, $sut->getExpectedTime());
self::assertEquals(1, $sut->count());
self::assertEquals([$summary], $sut->getSummaries());
self::assertInstanceOf(\Traversable::class, $sut->getIterator());
}
}

View File

@@ -0,0 +1,45 @@
<?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\WorkingTime\Model;
use App\WorkingTime\Model\YearSummary;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\YearSummary
*/
class YearSummaryTest extends TestCase
{
public function testDefaults(): void
{
$monthDate = new \DateTimeImmutable();
$sut = new YearSummary($monthDate, 'Foo-Bar');
self::assertEquals('Foo-Bar', $sut->getTitle());
self::assertEquals(0, $sut->getExpectedTime());
self::assertEquals(0, $sut->getActualTime());
}
public function testWithSummary(): void
{
$monthDate = new \DateTimeImmutable('2020-01-01');
$sut = new YearSummary($monthDate, 'Foo-Bar');
$monthSummary = $sut->getMonth(new \DateTimeImmutable('2020-04-27'));
$monthSummary->setActualTime(35679);
$monthSummary->setExpectedTime(135679);
$monthSummary = $sut->getMonth(new \DateTimeImmutable('2020-07-27'));
$monthSummary->setActualTime(95679);
$monthSummary->setExpectedTime(235679);
self::assertEquals(131358, $sut->getActualTime());
self::assertEquals(371358, $sut->getExpectedTime());
}
}

View File

@@ -0,0 +1,70 @@
<?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\WorkingTime\Model;
use App\Entity\User;
use App\Entity\WorkingTime;
use App\WorkingTime\Model\Year;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\WorkingTime\Model\Year
*/
class YearTest extends TestCase
{
public function testDefaults(): void
{
$year = new \DateTimeImmutable('2020-02-02');
$user = new User();
$user->setUserIdentifier('foooo');
$sut = new Year($year, $user);
$until = new \DateTimeImmutable('2020-09-25');
self::assertSame($user, $sut->getUser());
self::assertEquals(0, $sut->getActualTime());
self::assertEquals(0, $sut->getExpectedTime($until));
}
public function testWithSummary(): void
{
$year = new \DateTimeImmutable('2020-02-02');
$user = new User();
$user->setUserIdentifier('foooo');
$sut = new Year($year, $user);
$until = new \DateTimeImmutable('2020-09-25');
self::assertSame($user, $sut->getUser());
self::assertEquals(0, $sut->getActualTime());
self::assertEquals(0, $sut->getExpectedTime($until));
$workingTime = new WorkingTime(new User(), new \DateTimeImmutable('2020-04-14'));
$workingTime->setExpectedTime(54321);
$workingTime->setActualTime(12345);
$day = $sut->getMonth(new \DateTimeImmutable('2020-04-25'))->getDays()[13];
$day->setWorkingTime($workingTime);
self::assertEquals(12345, $sut->getActualTime());
self::assertEquals(54321, $sut->getExpectedTime($until));
$workingTime = new WorkingTime(new User(), new \DateTimeImmutable('2020-06-20'));
$workingTime->setExpectedTime(9843);
$workingTime->setActualTime(777);
$day = $sut->getMonth(new \DateTimeImmutable('2020-06-25'))->getDays()[19];
$day->setWorkingTime($workingTime);
self::assertEquals(13122, $sut->getActualTime());
self::assertEquals(64164, $sut->getExpectedTime($until));
self::assertEquals(54321, $sut->getExpectedTime(new \DateTimeImmutable('2020-04-25')));
self::assertEquals(0, $sut->getExpectedTime(new \DateTimeImmutable('2020-03-25')));
}
}

View File

@@ -4782,11 +4782,6 @@ parameters:
count: 1
path: Entity/UserTest.php
-
message: "#^Method App\\\\Tests\\\\Entity\\\\UserTest\\:\\:testColor\\(\\) has no return type specified\\.$#"
count: 1
path: Entity/UserTest.php
-
message: "#^Method App\\\\Tests\\\\Entity\\\\UserTest\\:\\:testDatetime\\(\\) has no return type specified\\.$#"
count: 1

View File

@@ -267,6 +267,18 @@
<target>Das Verzeichnis „%dir%“ ist schreibgeschützt.</target>
</trans-unit>
<!-- General labels -->
<trans-unit id="EYXzfTO" resname="deleted">
<source>deleted</source>
<target>Gelöscht</target>
</trans-unit>
<trans-unit id="EX27Gdt" resname="created_by">
<source>created_by</source>
<target>Erstellt von</target>
</trans-unit>
<trans-unit id="6Hip2aF" resname="created_at">
<source>created_at</source>
<target>Erstellt am</target>
</trans-unit>
<trans-unit id="DodjLNR" resname="date">
<source>date</source>
<target>Datum</target>
@@ -591,6 +603,86 @@
<source>profile.roles</source>
<target>Rollen</target>
</trans-unit>
<trans-unit id="eCEGRzN" resname="work_contract">
<source>work_contract</source>
<target>Arbeitsvertrag</target>
</trans-unit>
<trans-unit id="7_d7IB3" resname="evaluation">
<source>evaluation</source>
<target>Auswertung</target>
</trans-unit>
<trans-unit id="4lrfIsZ" resname="complete_month">
<source>complete_month</source>
<target>Monat abschliessen</target>
</trans-unit>
<trans-unit id="W8XQTkJ" resname="complete_month.help">
<source>complete_month.help</source>
<target>Sobald der Monat abgeschlossen ist, kann der Benutzer für diesen Zeitraum keine Zeiten mehr anlegen oder bearbeiten.</target>
</trans-unit>
<trans-unit id="eWmotjb" resname="completed_month_pdf">
<source>completed_month_pdf</source>
<target>Monats-Abschluss PDF</target>
</trans-unit>
<trans-unit id="AXr_aHI" resname="completed_month_pdf_note">
<source>completed_month_pdf_note</source>
<target>Dieses Dokument dient als Nachweis der geleisteten Arbeitszeiten.</target>
</trans-unit>
<trans-unit id="yXrOTI." resname="review">
<source>review</source>
<target>Überprüfung</target>
</trans-unit>
<trans-unit id="mS4SSmt" resname="work_times">
<source>work_times</source>
<target>Arbeitszeiten</target>
</trans-unit>
<trans-unit id="iaJj4va" resname="work_times_account">
<source>work_times_account</source>
<target>Arbeitszeitkonto</target>
</trans-unit>
<trans-unit id="1btzNDa" resname="work_times_should">
<source>work_times_should</source>
<target>Soll-Stunden</target>
</trans-unit>
<trans-unit id="PE2iiaW" resname="work_times_should.help">
<source>work_times_should.help</source>
<target>Die hier konfigurierten Werte werden zur Berechnung des Stundenkontos verwendet.</target>
</trans-unit>
<trans-unit id="s9NPI3v" resname="work_times_should.none_configured">
<source>work_times_should.none_configured</source>
<target>Für diesen Benutzer wurden in den Arbeitsvertragseinstellungen keine Sollstunden konfiguriert.</target>
</trans-unit>
<trans-unit id="31dFXhR" resname="work_times_is">
<source>work_times_is</source>
<target>Ist-Stunden</target>
</trans-unit>
<trans-unit id="uHrZlbq" resname="work_times_result">
<source>work_times_result</source>
<target>Geleistete Stunden</target>
</trans-unit>
<trans-unit id="8yqaKzX" resname="confirmed_by_at">
<source>confirmed_by_at</source>
<target>Bestätigt von %user% am %date%</target>
</trans-unit>
<trans-unit id="wZRRqg0" resname="manual_bookings">
<source>manual_bookings</source>
<target>Manuelle Buchungen</target>
</trans-unit>
<trans-unit id="IalLD5Q" resname="manual_bookings.work_contract_intro">
<source>manual_bookings.work_contract_intro</source>
<target>Manuelle Buchungen können weder nachträglich geändert noch gelöscht werden!</target>
</trans-unit>
<trans-unit id="znIJiOw" resname="manual_bookings.duration_help">
<source>manual_bookings.duration_help</source>
<target>Die Dauer kann sowohl positiv (Erhöhung der Ist-Zeit) als auch negativ (Reduzierung der Ist-Zeit) sein.</target>
</trans-unit>
<trans-unit id="M.zFjwe" resname="pto_year_holiday">
<source>pto_year_holiday</source>
<target>Urlaubstage pro Jahr</target>
</trans-unit>
<trans-unit id="gcFtM3o" resname="holiday">
<source>holiday</source>
<target>Urlaub</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences">
<source>profile.preferences</source>
<target>Einstellungen</target>

View File

@@ -267,6 +267,18 @@
<target>Directory "%dir%" is write protected.</target>
</trans-unit>
<!-- General labels -->
<trans-unit id="EYXzfTO" resname="deleted">
<source>deleted</source>
<target>Deleted</target>
</trans-unit>
<trans-unit id="EX27Gdt" resname="created_by">
<source>created_by</source>
<target>Created by</target>
</trans-unit>
<trans-unit id="6Hip2aF" resname="created_at">
<source>created_at</source>
<target>Created at</target>
</trans-unit>
<trans-unit id="DodjLNR" resname="date">
<source>date</source>
<target>Date</target>
@@ -591,6 +603,86 @@
<source>profile.roles</source>
<target>Roles</target>
</trans-unit>
<trans-unit id="eCEGRzN" resname="work_contract">
<source>work_contract</source>
<target>Employment contract</target>
</trans-unit>
<trans-unit id="7_d7IB3" resname="evaluation">
<source>evaluation</source>
<target>Evaluation</target>
</trans-unit>
<trans-unit id="4lrfIsZ" resname="complete_month">
<source>complete_month</source>
<target>Complete month</target>
</trans-unit>
<trans-unit id="W8XQTkJ" resname="complete_month.help">
<source>complete_month.help</source>
<target>After completing the month, the user will no longer be able to create or edit times for that period.</target>
</trans-unit>
<trans-unit id="eWmotjb" resname="completed_month_pdf">
<source>completed_month_pdf</source>
<target>Completed month PDF</target>
</trans-unit>
<trans-unit id="AXr_aHI" resname="completed_month_pdf_note">
<source>completed_month_pdf_note</source>
<target>This document serves as proof of the hours worked.</target>
</trans-unit>
<trans-unit id="yXrOTI." resname="review">
<source>review</source>
<target>Review</target>
</trans-unit>
<trans-unit id="mS4SSmt" resname="work_times">
<source>work_times</source>
<target>Working times</target>
</trans-unit>
<trans-unit id="iaJj4va" resname="work_times_account">
<source>work_times_account</source>
<target>Working time account</target>
</trans-unit>
<trans-unit id="1btzNDa" resname="work_times_should">
<source>work_times_should</source>
<target>Target-hours</target>
</trans-unit>
<trans-unit id="PE2iiaW" resname="work_times_should.help">
<source>work_times_should.help</source>
<target>The values configured here are used to calculate the hourly account.</target>
</trans-unit>
<trans-unit id="s9NPI3v" resname="work_times_should.none_configured">
<source>work_times_should.none_configured</source>
<target>No target hours have been configured for this user in the employment contract settings.</target>
</trans-unit>
<trans-unit id="31dFXhR" resname="work_times_is">
<source>work_times_is</source>
<target>Is-hours</target>
</trans-unit>
<trans-unit id="uHrZlbq" resname="work_times_result">
<source>work_times_result</source>
<target>Hours worked</target>
</trans-unit>
<trans-unit id="8yqaKzX" resname="confirmed_by_at">
<source>confirmed_by_at</source>
<target>Confirmed by %user% at %date%</target>
</trans-unit>
<trans-unit id="wZRRqg0" resname="manual_bookings">
<source>manual_bookings</source>
<target>Manual bookings</target>
</trans-unit>
<trans-unit id="IalLD5Q" resname="manual_bookings.work_contract_intro">
<source>manual_bookings.work_contract_intro</source>
<target>Manual bookings can neither be subsequently changed nor deleted!</target>
</trans-unit>
<trans-unit id="znIJiOw" resname="manual_bookings.duration_help">
<source>manual_bookings.duration_help</source>
<target>The duration can be both positive (increase of the is-time) and negative (reduction of the is-time).</target>
</trans-unit>
<trans-unit id="M.zFjwe" resname="pto_year_holiday">
<source>pto_year_holiday</source>
<target>Urlaubstage pro Jahr</target>
</trans-unit>
<trans-unit id="gcFtM3o" resname="holiday">
<source>holiday</source>
<target>Holiday</target>
</trans-unit>
<trans-unit id="MQKiG33" resname="profile.preferences">
<source>profile.preferences</source>
<target>Preferences</target>