diff --git a/composer.lock b/composer.lock index ff76e4a1..d30f4245 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/packages/kimai.yaml b/config/packages/kimai.yaml index cafe4af8..cc26e4a9 100644 --- a/config/packages/kimai.yaml +++ b/config/packages/kimai.yaml @@ -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'] diff --git a/config/packages/tabler.yaml b/config/packages/tabler.yaml index b7006694..aa28e4ab 100644 --- a/config/packages/tabler.yaml +++ b/config/packages/tabler.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index b233ae3d..3d027024 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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'] diff --git a/migrations/Version20230327143628.php b/migrations/Version20230327143628.php new file mode 100644 index 00000000..e34cd8e1 --- /dev/null +++ b/migrations/Version20230327143628.php @@ -0,0 +1,52 @@ +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; + } +} diff --git a/src/Controller/ContractController.php b/src/Controller/ContractController.php new file mode 100644 index 00000000..0756aed9 --- /dev/null +++ b/src/Controller/ContractController.php @@ -0,0 +1,93 @@ +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(), + ]); + } +} diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php index a72d061c..edd07cd7 100644 --- a/src/Controller/ProfileController.php +++ b/src/Controller/ProfileController.php @@ -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 diff --git a/src/Entity/User.php b/src/Entity/User.php index d3ca244e..f3374500 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -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')) + }; + } } diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php index 05b9a3ee..36e1841c 100644 --- a/src/Entity/UserPreference.php +++ b/src/Entity/UserPreference.php @@ -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] diff --git a/src/Entity/WorkingTime.php b/src/Entity/WorkingTime.php new file mode 100644 index 00000000..0898c026 --- /dev/null +++ b/src/Entity/WorkingTime.php @@ -0,0 +1,113 @@ +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; + } +} diff --git a/src/Event/WorkContractDetailControllerEvent.php b/src/Event/WorkContractDetailControllerEvent.php new file mode 100644 index 00000000..3708f827 --- /dev/null +++ b/src/Event/WorkContractDetailControllerEvent.php @@ -0,0 +1,48 @@ + + */ + 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; + } +} diff --git a/src/Event/WorkingTimeYearSummaryEvent.php b/src/Event/WorkingTimeYearSummaryEvent.php new file mode 100644 index 00000000..2482b1e5 --- /dev/null +++ b/src/Event/WorkingTimeYearSummaryEvent.php @@ -0,0 +1,37 @@ +yearPerUserSummary->getYear(); + } + + public function getUntil(): \DateTimeInterface + { + return $this->until; + } + + public function addSummary(YearSummary $yearSummary): void + { + $this->yearPerUserSummary->addSummary($yearSummary); + } +} diff --git a/src/EventSubscriber/Actions/UserFormsSubscriber.php b/src/EventSubscriber/Actions/UserFormsSubscriber.php index 209859b4..289499be 100644 --- a/src/EventSubscriber/Actions/UserFormsSubscriber.php +++ b/src/EventSubscriber/Actions/UserFormsSubscriber.php @@ -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']); + } } } diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php index e9b42ef1..1269fc5c 100644 --- a/src/EventSubscriber/MenuSubscriber.php +++ b/src/EventSubscriber/MenuSubscriber.php @@ -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']); diff --git a/src/Form/ContractByUserForm.php b/src/Form/ContractByUserForm.php new file mode 100644 index 00000000..53d5448a --- /dev/null +++ b/src/Form/ContractByUserForm.php @@ -0,0 +1,48 @@ +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', + ]); + } +} diff --git a/src/Form/UserContractType.php b/src/Form/UserContractType.php new file mode 100644 index 00000000..65f6d1dc --- /dev/null +++ b/src/Form/UserContractType.php @@ -0,0 +1,63 @@ + + */ +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', + ]); + } +} diff --git a/src/Model/Day.php b/src/Model/Day.php new file mode 100644 index 00000000..6b260790 --- /dev/null +++ b/src/Model/Day.php @@ -0,0 +1,24 @@ +day; + } +} diff --git a/src/Model/Month.php b/src/Model/Month.php new file mode 100644 index 00000000..06fa8d4d --- /dev/null +++ b/src/Model/Month.php @@ -0,0 +1,54 @@ +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); + } +} diff --git a/src/Model/Year.php b/src/Model/Year.php new file mode 100644 index 00000000..d9900c67 --- /dev/null +++ b/src/Model/Year.php @@ -0,0 +1,60 @@ +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); + } +} diff --git a/src/Repository/WorkingTimeRepository.php b/src/Repository/WorkingTimeRepository.php new file mode 100644 index 00000000..d14ffb51 --- /dev/null +++ b/src/Repository/WorkingTimeRepository.php @@ -0,0 +1,89 @@ + + * @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 + */ + 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 + } +} diff --git a/src/Voter/UserVoter.php b/src/Voter/UserVoter.php index 46c46a12..b240aed1 100644 --- a/src/Voter/UserVoter.php +++ b/src/Voter/UserVoter.php @@ -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; diff --git a/src/WorkingTime/Model/BoxConfiguration.php b/src/WorkingTime/Model/BoxConfiguration.php new file mode 100644 index 00000000..19864b90 --- /dev/null +++ b/src/WorkingTime/Model/BoxConfiguration.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/src/WorkingTime/Model/Day.php b/src/WorkingTime/Model/Day.php new file mode 100644 index 00000000..e6ecedb8 --- /dev/null +++ b/src/WorkingTime/Model/Day.php @@ -0,0 +1,28 @@ +workingTime; + } + + public function setWorkingTime(?WorkingTime $workingTime): void + { + $this->workingTime = $workingTime; + } +} diff --git a/src/WorkingTime/Model/Month.php b/src/WorkingTime/Model/Month.php new file mode 100644 index 00000000..03dc4881 --- /dev/null +++ b/src/WorkingTime/Model/Month.php @@ -0,0 +1,97 @@ + 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; + } +} diff --git a/src/WorkingTime/Model/MonthSummary.php b/src/WorkingTime/Model/MonthSummary.php new file mode 100644 index 00000000..5bd0be33 --- /dev/null +++ b/src/WorkingTime/Model/MonthSummary.php @@ -0,0 +1,38 @@ +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; + } +} diff --git a/src/WorkingTime/Model/Year.php b/src/WorkingTime/Model/Year.php new file mode 100644 index 00000000..23f33a99 --- /dev/null +++ b/src/WorkingTime/Model/Year.php @@ -0,0 +1,57 @@ + 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; + } +} diff --git a/src/WorkingTime/Model/YearPerUserSummary.php b/src/WorkingTime/Model/YearPerUserSummary.php new file mode 100644 index 00000000..bfd7a988 --- /dev/null +++ b/src/WorkingTime/Model/YearPerUserSummary.php @@ -0,0 +1,78 @@ + + */ +final class YearPerUserSummary implements \Countable, \IteratorAggregate +{ + /** @var array */ + 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; + } +} diff --git a/src/WorkingTime/Model/YearSummary.php b/src/WorkingTime/Model/YearSummary.php new file mode 100644 index 00000000..7769b987 --- /dev/null +++ b/src/WorkingTime/Model/YearSummary.php @@ -0,0 +1,54 @@ + 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; + } +} diff --git a/src/WorkingTime/WorkingTimeService.php b/src/WorkingTime/WorkingTimeService.php new file mode 100644 index 00000000..93d5641e --- /dev/null +++ b/src/WorkingTime/WorkingTimeService.php @@ -0,0 +1,156 @@ +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 + */ + 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 + } +} diff --git a/templates/contract/status.html.twig b/templates/contract/status.html.twig new file mode 100644 index 00000000..94a5f846 --- /dev/null +++ b/templates/contract/status.html.twig @@ -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 %} +
+
+ {{ form_start(form, {'attr': {'class': 'form-reporting', 'id': 'report-form'}}) }} +
+ {{ form_widget(form.date) }} + {% if form.user is defined %} + {{ form_widget(form.user, {'label': false}) }} + {% elseif app.user != user %} + {{ widgets.username(user) }} + {% endif %} +
+ {{ form_end(form) }} +
+
+ {% 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 %} + + + + + + {% for month in year.months %} + + {% endfor %} + + + + {% 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 %} + + + + {% 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}) %} + + {% endfor %} + + {% endfor %} + + + + + + {% for month in year.months %} + {% set dateKey = '_' ~ month.month.format('m') %} + + {% endfor %} + + +
{{ 'sum.total'|trans }}{{ month.month|month_name }}
{{ summary.title|trans }} + {% if summary.expectedTime > 0 %} + {{ work_times_result(summary.expectedTime, summary.actualTime, decimal) }} + {% else %} + {{ summary.actualTime|duration(decimal) }} + {% endif %} + + {% if now < month.month and monthShould > 0 %} + {{ monthShould|duration(decimal) }} + {% else %} + {{ work_times_result(monthShould, monthIs, decimal) }} + {% endif %} +
+ {{ work_times_result(summaries.expectedTime, summaries.actualTime, decimal) }} + + {{ work_times_result(monthTotalShould[dateKey], monthTotalIs[dateKey], decimal) }} +
+ {% 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 %} +
+
+
{{ 'agendaWeek'|trans }}
+
{{ expectedWeekTimes|duration }}
+
+
+
{{ 'Monday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursMonday|duration }}
+
+
+
{{ 'Tuesday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursTuesday|duration }}
+
+
+
{{ 'Wednesday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursWednesday|duration }}
+
+
+
{{ 'Thursday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursThursday|duration }}
+
+
+
{{ 'Friday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursFriday|duration }}
+
+
+
{{ 'Saturday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursSaturday|duration }}
+
+
+
{{ 'Sunday'|trans({}, 'system-configuration') }}
+
{{ user.workHoursSunday|duration }}
+
+
+ {% 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 %} + + + + {% if withWorkHourConfiguration %} + + {% endif %} + + + + + {% for day in 1..31 %} + + {% endfor %} + + + + + {% for month in year.months %} + {% set monthShould = month.expectedTime(now) %} + {% set monthIs = month.actualTime %} + {% set showStats = now > month.month or monthIs > 0 %} + + {% if withWorkHourConfiguration %} + + {% endif %} + + + + + {% 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 %} + + {% set dayCount = dayCount + 1 %} + {% endfor %} + {% if dayCount < 31 %} + {% for day in 1..(31-dayCount)%} + + {% endfor %} + {% endif %} + + + {% endfor %} + + + + {% if withWorkHourConfiguration %} + + {% endif %} + + + + + {% for day in 1..31 %} + + {% endfor %} + + + +
{{ 'month'|trans }}{{ 'sum.total'|trans }}{{ 'work_times_should'|trans }}{{ 'work_times_is'|trans }}{% if day < 10 %}0{% endif %}{{ day }}
+ {% if month.locked %} + + {% else %} + + {% endif %} + + {{ month.month|month_name }} + + {% if showStats %} + {{ work_times_result(monthShould, monthIs, decimal) }} + {% endif %} + + {{ monthShould|duration(decimal) }} + + {{ monthIs|duration(decimal) }} + + {% 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 %} + + {% set event = actions(app.user, 'contract_month', 'index', {'year': year, 'month': month}) %} + {{ table_actions(event.actions) }} +
+ {{ work_times_result(yearShould, yearIs, decimal) }} + + {{ yearShould|duration(decimal) }} + + {{ yearIs|duration(decimal) }} +
+ {% endblock %} + {% endembed %} + {% endif %} + + {% for controller in boxes %} + {{ render(controller(controller, {'year': year, 'boxConfiguration': box_configuration})) }} + {% endfor %} + +{% endblock %} + +{% block javascripts %} + {{ parent() }} + +{% endblock %} diff --git a/templates/macros/widgets.html.twig b/templates/macros/widgets.html.twig index fdf02839..a2ba9fc2 100644 --- a/templates/macros/widgets.html.twig +++ b/templates/macros/widgets.html.twig @@ -514,4 +514,17 @@ KimaiReloadPageWidget.create('{{ events }}', {% if full_reload is same as true %}true{% else %}false{% endif %}); }); -{% endmacro %} \ No newline at end of file +{% endmacro %} + +{% macro work_times_result(should, actual, decimal) %} + {% set result = actual - should %} + {% if result == 0 %} + {{ result|duration(decimal) }} + {% elseif should == 0 and actual > 0 %} + +{{ actual|duration(decimal) }} + {% elseif result < 0 %} + {{ result|duration(decimal) }} + {% else %} + +{{ result|duration(decimal) }} + {% endif %} +{% endmacro %} diff --git a/templates/user/contract.html.twig b/templates/user/contract.html.twig new file mode 100644 index 00000000..d586e7bd --- /dev/null +++ b/templates/user/contract.html.twig @@ -0,0 +1,41 @@ +{% extends 'user/form.html.twig' %} + +{% block form_content %} + +

{{ 'work_times_should'|trans }}

+ + + + + + + + + + + + + + + + + + + + + + + + +
{{ 'Monday'|trans({}, 'system-configuration') }}{{ 'Tuesday'|trans({}, 'system-configuration') }}{{ 'Wednesday'|trans({}, 'system-configuration') }}{{ 'Thursday'|trans({}, 'system-configuration') }}{{ 'Friday'|trans({}, 'system-configuration') }}{{ 'Saturday'|trans({}, 'system-configuration') }}{{ 'Sunday'|trans({}, 'system-configuration') }}
{{ form_widget(form.workHoursMonday) }}{{ form_widget(form.workHoursTuesday) }}{{ form_widget(form.workHoursWednesday) }}{{ form_widget(form.workHoursThursday) }}{{ form_widget(form.workHoursFriday) }}{{ form_widget(form.workHoursSaturday) }}{{ form_widget(form.workHoursSunday) }}
+ +

{{ 'work_times_should.help'|trans }}

+ + {% if form.holidaysPerYear is defined %} +

{{ 'pto_year_holiday'|trans }}

+ + {{ form_rest(form) }} + {% endif %} + + +{% endblock %} diff --git a/templates/user/form.html.twig b/templates/user/form.html.twig index 65e48807..a9feebbe 100644 --- a/templates/user/form.html.twig +++ b/templates/user/form.html.twig @@ -17,7 +17,7 @@ {% block form_body %} {{ form_start(form) }} -
+
{% block form_pre_content %}{% endblock %} {% block form_content %} {{ form_widget(form) }} diff --git a/tests/Controller/ContractControllerTest.php b/tests/Controller/ContractControllerTest.php new file mode 100644 index 00000000..95e5c7c8 --- /dev/null +++ b/tests/Controller/ContractControllerTest.php @@ -0,0 +1,45 @@ +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()); + } +} diff --git a/tests/Controller/PermissionControllerTest.php b/tests/Controller/PermissionControllerTest.php index 1ea04fe1..fceef609 100644 --- a/tests/Controller/PermissionControllerTest.php +++ b/tests/Controller/PermissionControllerTest.php @@ -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'), ]); diff --git a/tests/Controller/ProfileControllerTest.php b/tests/Controller/ProfileControllerTest.php index 1d4d86c7..4ff6530c 100644 --- a/tests/Controller/ProfileControllerTest.php +++ b/tests/Controller/ProfileControllerTest.php @@ -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()); + } } diff --git a/tests/Entity/UserTest.php b/tests/Entity/UserTest.php index f99a2972..8a2f258c 100644 --- a/tests/Entity/UserTest.php +++ b/tests/Entity/UserTest.php @@ -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()); diff --git a/tests/Entity/WorkingTimeTest.php b/tests/Entity/WorkingTimeTest.php new file mode 100644 index 00000000..d802eb5b --- /dev/null +++ b/tests/Entity/WorkingTimeTest.php @@ -0,0 +1,54 @@ +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()); + } +} diff --git a/tests/Event/WorkContractDetailControllerEventTest.php b/tests/Event/WorkContractDetailControllerEventTest.php new file mode 100644 index 00000000..d940886e --- /dev/null +++ b/tests/Event/WorkContractDetailControllerEventTest.php @@ -0,0 +1,36 @@ +getYear()); + self::assertEquals([], $sut->getController()); + + $sut->addController('foo'); + $sut->addController('bar'); + self::assertEquals(['foo', 'bar'], $sut->getController()); + } +} diff --git a/tests/Event/WorkingTimeYearSummaryEventTest.php b/tests/Event/WorkingTimeYearSummaryEventTest.php new file mode 100644 index 00000000..4c55c9bb --- /dev/null +++ b/tests/Event/WorkingTimeYearSummaryEventTest.php @@ -0,0 +1,43 @@ +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()); + } +} diff --git a/tests/Model/DayTest.php b/tests/Model/DayTest.php new file mode 100644 index 00000000..7fe4bb4f --- /dev/null +++ b/tests/Model/DayTest.php @@ -0,0 +1,26 @@ +getDay()); + } +} diff --git a/tests/Model/MonthTest.php b/tests/Model/MonthTest.php new file mode 100644 index 00000000..96030822 --- /dev/null +++ b/tests/Model/MonthTest.php @@ -0,0 +1,32 @@ + $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()); + } + } +} diff --git a/tests/Model/YearTest.php b/tests/Model/YearTest.php new file mode 100644 index 00000000..4077d600 --- /dev/null +++ b/tests/Model/YearTest.php @@ -0,0 +1,35 @@ +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()); + } + } +} diff --git a/tests/WorkingTime/Model/BoxConfigurationTest.php b/tests/WorkingTime/Model/BoxConfigurationTest.php new file mode 100644 index 00000000..d6d9c6d2 --- /dev/null +++ b/tests/WorkingTime/Model/BoxConfigurationTest.php @@ -0,0 +1,37 @@ +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()); + } +} diff --git a/tests/WorkingTime/Model/DayTest.php b/tests/WorkingTime/Model/DayTest.php new file mode 100644 index 00000000..52aa93c1 --- /dev/null +++ b/tests/WorkingTime/Model/DayTest.php @@ -0,0 +1,35 @@ +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()); + } +} diff --git a/tests/WorkingTime/Model/MonthSummaryTest.php b/tests/WorkingTime/Model/MonthSummaryTest.php new file mode 100644 index 00000000..35d89909 --- /dev/null +++ b/tests/WorkingTime/Model/MonthSummaryTest.php @@ -0,0 +1,35 @@ +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()); + } +} diff --git a/tests/WorkingTime/Model/MonthTest.php b/tests/WorkingTime/Model/MonthTest.php new file mode 100644 index 00000000..d8fce323 --- /dev/null +++ b/tests/WorkingTime/Model/MonthTest.php @@ -0,0 +1,56 @@ + $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()); + } + } + } +} diff --git a/tests/WorkingTime/Model/YearPerUserSummaryTest.php b/tests/WorkingTime/Model/YearPerUserSummaryTest.php new file mode 100644 index 00000000..e471db20 --- /dev/null +++ b/tests/WorkingTime/Model/YearPerUserSummaryTest.php @@ -0,0 +1,65 @@ +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()); + } +} diff --git a/tests/WorkingTime/Model/YearSummaryTest.php b/tests/WorkingTime/Model/YearSummaryTest.php new file mode 100644 index 00000000..4437baf2 --- /dev/null +++ b/tests/WorkingTime/Model/YearSummaryTest.php @@ -0,0 +1,45 @@ +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()); + } +} diff --git a/tests/WorkingTime/Model/YearTest.php b/tests/WorkingTime/Model/YearTest.php new file mode 100644 index 00000000..60e2c2f5 --- /dev/null +++ b/tests/WorkingTime/Model/YearTest.php @@ -0,0 +1,70 @@ +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'))); + } +} diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 8fd61252..2e28fc43 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -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 diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf index eb62d3d7..465655d2 100644 --- a/translations/messages.de.xlf +++ b/translations/messages.de.xlf @@ -267,6 +267,18 @@ Das Verzeichnis „%dir%“ ist schreibgeschützt. + + deleted + Gelöscht + + + created_by + Erstellt von + + + created_at + Erstellt am + date Datum @@ -591,6 +603,86 @@ profile.roles Rollen + + work_contract + Arbeitsvertrag + + + evaluation + Auswertung + + + complete_month + Monat abschliessen + + + complete_month.help + Sobald der Monat abgeschlossen ist, kann der Benutzer für diesen Zeitraum keine Zeiten mehr anlegen oder bearbeiten. + + + completed_month_pdf + Monats-Abschluss PDF + + + completed_month_pdf_note + Dieses Dokument dient als Nachweis der geleisteten Arbeitszeiten. + + + review + Überprüfung + + + work_times + Arbeitszeiten + + + work_times_account + Arbeitszeitkonto + + + work_times_should + Soll-Stunden + + + work_times_should.help + Die hier konfigurierten Werte werden zur Berechnung des Stundenkontos verwendet. + + + work_times_should.none_configured + Für diesen Benutzer wurden in den Arbeitsvertragseinstellungen keine Sollstunden konfiguriert. + + + work_times_is + Ist-Stunden + + + work_times_result + Geleistete Stunden + + + confirmed_by_at + Bestätigt von %user% am %date% + + + manual_bookings + Manuelle Buchungen + + + manual_bookings.work_contract_intro + Manuelle Buchungen können weder nachträglich geändert noch gelöscht werden! + + + manual_bookings.duration_help + Die Dauer kann sowohl positiv (Erhöhung der Ist-Zeit) als auch negativ (Reduzierung der Ist-Zeit) sein. + + + pto_year_holiday + Urlaubstage pro Jahr + + + holiday + Urlaub + profile.preferences Einstellungen diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index d971167f..122a2084 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -267,6 +267,18 @@ Directory "%dir%" is write protected. + + deleted + Deleted + + + created_by + Created by + + + created_at + Created at + date Date @@ -591,6 +603,86 @@ profile.roles Roles + + work_contract + Employment contract + + + evaluation + Evaluation + + + complete_month + Complete month + + + complete_month.help + After completing the month, the user will no longer be able to create or edit times for that period. + + + completed_month_pdf + Completed month PDF + + + completed_month_pdf_note + This document serves as proof of the hours worked. + + + review + Review + + + work_times + Working times + + + work_times_account + Working time account + + + work_times_should + Target-hours + + + work_times_should.help + The values configured here are used to calculate the hourly account. + + + work_times_should.none_configured + No target hours have been configured for this user in the employment contract settings. + + + work_times_is + Is-hours + + + work_times_result + Hours worked + + + confirmed_by_at + Confirmed by %user% at %date% + + + manual_bookings + Manual bookings + + + manual_bookings.work_contract_intro + Manual bookings can neither be subsequently changed nor deleted! + + + manual_bookings.duration_help + The duration can be both positive (increase of the is-time) and negative (reduction of the is-time). + + + pto_year_holiday + Urlaubstage pro Jahr + + + holiday + Holiday + profile.preferences Preferences