diff --git a/phpstan.neon b/phpstan.neon index 9f4879aa..82200999 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2010,7 +2010,7 @@ parameters: - message: "#^Cannot clone mixed\\.$#" - count: 2 + count: 1 path: src/Form/QuickEntryForm.php - @@ -2838,21 +2838,6 @@ parameters: count: 1 path: src/Form/Type/QuickEntryTimesheetType.php - - - message: "#^Cannot access offset 'activity' on mixed\\.$#" - count: 1 - path: src/Form/Type/QuickEntryWeekType.php - - - - message: "#^Cannot access offset 'project' on mixed\\.$#" - count: 1 - path: src/Form/Type/QuickEntryWeekType.php - - - - message: "#^Cannot clone mixed\\.$#" - count: 1 - path: src/Form/Type/QuickEntryWeekType.php - - message: "#^Method App\\\\Form\\\\Type\\\\SearchTermType\\:\\:buildForm\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 diff --git a/src/Controller/QuickEntryController.php b/src/Controller/QuickEntryController.php index ed344406..3eb0bc8d 100644 --- a/src/Controller/QuickEntryController.php +++ b/src/Controller/QuickEntryController.php @@ -20,6 +20,7 @@ use App\Repository\TimesheetRepository; use App\Timesheet\FavoriteRecordService; use App\Timesheet\TimesheetService; use App\Utils\PageSetup; +use App\WorkingTime\WorkingTimeService; use Psr\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,6 +39,7 @@ final class QuickEntryController extends AbstractController private readonly TimesheetRepository $repository, private readonly FavoriteRecordService $favoriteRecordService, private readonly EventDispatcherInterface $dispatcher, + private readonly WorkingTimeService $workingTimeService, ) { } @@ -114,37 +116,46 @@ final class QuickEntryController extends AbstractController ksort($rows); - // attach recent activities - $amount = $this->configuration->getQuickEntriesRecentAmount(); - if ($amount > 0) { - $takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks'); - $startFrom = null; - if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) { - $startFrom = clone $startWeek; - $startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks)); - } + // this should also check via lock service + $locked = $this->workingTimeService->isApproved($user, $endWeek); - $favorites = $this->favoriteRecordService->favoriteEntries($user, $amount); - foreach ($favorites as $favorite) { - $timesheet = $favorite->getTimesheet(); - if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) { - continue; + if (!$locked) { + // attach recent activities + $amount = $this->configuration->getQuickEntriesRecentAmount(); + if ($amount > 0) { + $takeOverWeeks = $this->configuration->find('quick_entry.recent_activity_weeks'); + $startFrom = null; + if ($takeOverWeeks !== null && \intval($takeOverWeeks) > 0) { + $startFrom = clone $startWeek; + $startFrom->modify(\sprintf('-%s weeks', $takeOverWeeks)); } - $id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId(); - if (\array_key_exists($id, $rows)) { - continue; + $favorites = $this->favoriteRecordService->favoriteEntries($user, $amount); + foreach ($favorites as $favorite) { + $timesheet = $favorite->getTimesheet(); + if ($startFrom !== null && !$favorite->isFavorite() && $startFrom > $timesheet->getBegin()) { + continue; + } + + $id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId(); + if (\array_key_exists($id, $rows)) { + continue; + } + // edge case: a project that starts and ends between the start and end date allows to select it from the dropdown, + // but it is better to hide a row than displaying already ended projects + if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) { + continue; + } + // make sure no invisible entries are included + if (!$this->isGranted('start', $timesheet)) { + continue; + } + $rows[$id] = [ + 'days' => $week, + 'project' => $timesheet->getProject(), + 'activity' => $timesheet->getActivity() + ]; } - // there is an edge case possible with a project that starts and ends between the start and end date - // user could still select it from the dropdown, but it is better to hide a row than displaying already ended projects - if ($timesheet->getProject() !== null && (!$timesheet->getProject()->isVisibleAtDate($startWeek) && !$timesheet->getProject()->isVisibleAtDate($endWeek))) { - continue; - } - $rows[$id] = [ - 'days' => $week, - 'project' => $timesheet->getProject(), - 'activity' => $timesheet->getActivity() - ]; } } @@ -165,6 +176,7 @@ final class QuickEntryController extends AbstractController if (!\array_key_exists('entry', $day)) { // fill all rows and columns to make sure we do not have missing records $tmp = $this->timesheetService->createNewTimesheet($user); + $tmp->setDuration(null); $tmp->setProject($row['project']); $tmp->setActivity($row['activity']); $newTime = \DateTime::createFromInterface($day['day']); @@ -185,6 +197,7 @@ final class QuickEntryController extends AbstractController $empty->markAsPrototype(); foreach ($week as $dayId => $day) { $tmp = $this->timesheetService->createNewTimesheet($user); + $tmp->setDuration(null); $newTime = \DateTime::createFromInterface($day['day']); $newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0); $tmp->setBegin($newTime); @@ -194,18 +207,20 @@ final class QuickEntryController extends AbstractController // add empty rows for simpler starting $minRows = \intval($this->configuration->find('quick_entry.minimum_rows')); - if ($formModel->countRows() < $minRows) { + if (!$locked && $formModel->countRows() < $minRows) { $newRows = $minRows - $formModel->countRows(); for ($a = 0; $a < $newRows; $a++) { $model = $formModel->addRow($user); foreach ($week as $dayId => $day) { $tmp = $this->timesheetService->createNewTimesheet($user); + $tmp->setDuration(null); $newTime = \DateTime::createFromInterface($day['day']); $newTime = $newTime->setTime($defaultHour, $defaultMinute, 0, 0); $tmp->setBegin($newTime); $this->timesheetService->prepareNewTimesheet($tmp); $model->addTimesheet($tmp); } + $model->setMetaFields($metaFields); } } @@ -229,12 +244,14 @@ final class QuickEntryController extends AbstractController foreach ($tmpModel->getTimesheets() as $timesheet) { if ($timesheet->getId() !== null) { $duration = $timesheet->getDuration(false); - // previously running timesheets were deleted, which was wrong - // so now we distinguish between running timesheets and null duration + // running timesheets also have a empty duration. + // we distinguish them from temporary ones, to make sure they will not be deleted if ($timesheet->isRunning()) { $saveTimesheets[] = $timesheet; } elseif ($duration === null) { - $deleteTimesheets[] = $timesheet; + if ($this->isGranted('delete', $timesheet)) { + $deleteTimesheets[] = $timesheet; + } } else { $saveTimesheets[] = $timesheet; } @@ -248,14 +265,22 @@ final class QuickEntryController extends AbstractController try { $saved = false; - if (\count($deleteTimesheets) > 0 && $this->isGranted('delete_own_timesheet')) { + if (\count($deleteTimesheets) > 0) { $this->timesheetService->deleteMultipleTimesheets($deleteTimesheets); $saved = true; } if (\count($saveTimesheets) > 0) { - $this->timesheetService->updateMultipleTimesheets($saveTimesheets); - $saved = true; + $saveMe = []; + foreach ($saveTimesheets as $timesheet) { + if ($timesheet->getId() === null || $this->isGranted('edit', $timesheet)) { + $saveMe[] = $timesheet; + } + } + if (\count($saveMe) > 0) { + $this->timesheetService->updateMultipleTimesheets($saveMe); + $saved = true; + } } if ($saved) { @@ -278,6 +303,7 @@ final class QuickEntryController extends AbstractController 'days' => $week, 'form' => $form->createView(), 'metaColumns' => $metaFields, + 'locked' => $locked, ]); } } diff --git a/src/Form/QuickEntryForm.php b/src/Form/QuickEntryForm.php index 5a416090..59601c4d 100644 --- a/src/Form/QuickEntryForm.php +++ b/src/Form/QuickEntryForm.php @@ -18,6 +18,7 @@ use Symfony\Component\Form\CallbackTransformer; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\All; use Symfony\Component\Validator\Constraints\Valid; @@ -92,13 +93,16 @@ final class QuickEntryForm extends AbstractType 'entry_options' => [ 'label' => false, 'duration_minutes' => $this->configuration->getTimesheetIncrementDuration(), - // this is NOT the start_date, because it would prevent projects from appearing - // in the first days of the week, if the projects ends at the end of the week - // the validation still triggers if the user selects days outside the project range + // this is NOT the start_date, because it would prevent projects from appearing in the + // first days of the week if the project ends at the end of the week. + // the validation still triggers if the user selects days outside the project range. 'start_date' => $options['end_date'], 'end_date' => $options['end_date'], 'empty_data' => function (FormInterface $form) use ($options) { - return clone $options['prototype_data']; + if ($options['prototype_data'] instanceof QuickEntryModel) { + return clone $options['prototype_data']; + } + throw new \Exception('Invalid Prototype given'); }, 'prototype_data' => clone $options['prototype_data'], ], @@ -111,6 +115,25 @@ final class QuickEntryForm extends AbstractType ]); } + public function finishView(FormView $view, FormInterface $form, array $options): void + { + usort($view['rows']->children, function (FormView $a, FormView $b) { + /** @var \App\Model\QuickEntryModel $objectA */ + $objectA = $a->vars['data']; + /** @var \App\Model\QuickEntryModel $objectB */ + $objectB = $b->vars['data']; + + $existingA = $objectA->hasExistingTimesheet(); + $existingB = $objectB->hasExistingTimesheet(); + + if ($existingA === $existingB) { + return 0; + } + + return ($existingA && !$existingB) ? -1 : 1; + }); + } + public function configureOptions(OptionsResolver $resolver): void { $start = new \DateTime(); diff --git a/src/Form/Type/QuickEntryTimesheetType.php b/src/Form/Type/QuickEntryTimesheetType.php index 8359bc9d..65d0db3e 100644 --- a/src/Form/Type/QuickEntryTimesheetType.php +++ b/src/Form/Type/QuickEntryTimesheetType.php @@ -20,7 +20,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; final class QuickEntryTimesheetType extends AbstractType { - public function __construct(private Security $security) + public function __construct(private readonly Security $security) { } @@ -60,8 +60,28 @@ final class QuickEntryTimesheetType extends AbstractType $event->getForm()->get('duration')->setData(null); } - if (null !== $data && !$this->security->isGranted('edit', $data)) { + if ($data instanceof Timesheet && !$this->security->isGranted('edit', $data)) { + $event->getForm()->remove('duration'); $event->getForm()->add('duration', DurationType::class, array_merge(['disabled' => true], $durationOptions)); + + $mainForm = $event->getForm()->getParent()?->getParent(); + if ($mainForm === null) { + return; + } + + $isNew = $data->getId() === null; + + foreach($mainForm->all() as $key => $child) { + if ($key === 'timesheets') { + continue; + } + if ($child->isDisabled() || $isNew) { + continue; + } + $type = \get_class($child->getConfig()->getType()->getInnerType()); + $mainForm->remove($key); + $mainForm->add($key, $type, array_merge($child->getConfig()->getOptions(), ['disabled' => true])); + } } } ); diff --git a/src/Form/Type/QuickEntryWeekType.php b/src/Form/Type/QuickEntryWeekType.php index cb249456..2d0b5f08 100644 --- a/src/Form/Type/QuickEntryWeekType.php +++ b/src/Form/Type/QuickEntryWeekType.php @@ -9,7 +9,6 @@ namespace App\Form\Type; -use App\Entity\User; use App\Model\QuickEntryModel; use App\Validator\Constraints\QuickEntryTimesheet; use DateTime; @@ -77,21 +76,6 @@ final class QuickEntryWeekType extends AbstractType }; $builder->addEventListener(FormEvents::PRE_SET_DATA, $activityFunction); - $activityPreSubmitFunction = function (FormEvent $event) use ($activityOptions) { - $data = $event->getData(); - - if (isset($data['project']) && !empty($data['project'])) { - $activityOptions['projects'] = [$data['project']]; - } - - if (isset($data['activity']) && !empty($data['activity'])) { - $activityOptions['activities'] = [$data['activity']]; - } - - $event->getForm()->add('activity', ActivityType::class, $activityOptions); - }; - $builder->addEventListener(FormEvents::PRE_SUBMIT, $activityPreSubmitFunction); - $builder->add('metaFields', MetaFieldsCollectionType::class); $builder->add('timesheets', CollectionType::class, [ @@ -112,13 +96,13 @@ final class QuickEntryWeekType extends AbstractType ]); $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { - if ($event->getData() === null) { + if ($event->getData() === null && $options['prototype_data'] instanceof QuickEntryModel) { $event->setData(clone $options['prototype_data']); } }); $builder->addModelTransformer(new CallbackTransformer( - function ($transformValue) use ($options) { + function ($transformValue) { /** @var QuickEntryModel|null $transformValue */ if ($transformValue === null || $transformValue->isPrototype()) { return $transformValue; @@ -133,9 +117,6 @@ final class QuickEntryWeekType extends AbstractType } $user = $transformValue->getUser(); - if ($user === null && $options['user'] instanceof User) { - $user = $options['user']; - } foreach ($transformValue->getTimesheets() as $timesheet) { $timesheet->setUser($user); $timesheet->setProject($project); @@ -162,9 +143,7 @@ final class QuickEntryWeekType extends AbstractType $activity = $data->getActivity(); foreach ($newRecords as $record) { - if ($user !== null) { - $record->setUser($user); - } + $record->setUser($user); if ($project !== null) { $record->setProject($project); } diff --git a/src/Timesheet/LockdownService.php b/src/Timesheet/LockdownService.php index a8ba8d8e..db697183 100644 --- a/src/Timesheet/LockdownService.php +++ b/src/Timesheet/LockdownService.php @@ -17,7 +17,7 @@ final class LockdownService { private ?bool $isActive = null; - public function __construct(private SystemConfiguration $configuration) + public function __construct(private readonly SystemConfiguration $configuration) { } @@ -225,7 +225,7 @@ final class LockdownService return true; } - // further validate entries inside of the most recent lockdown + // further validate entries inside the most recent lockdown if ($timesheetStart >= $lockdownStart) { // if grace period is still in effect, validation succeeds if ($now <= $lockdownGrace) { diff --git a/templates/quick-entry/index.html.twig b/templates/quick-entry/index.html.twig index 9ee323f8..f3cb0851 100644 --- a/templates/quick-entry/index.html.twig +++ b/templates/quick-entry/index.html.twig @@ -13,11 +13,15 @@ {{ form_errors(form) }} {% endblock %} {% block box_after %} + {% if locked %} + {{ widgets.alert('warning', 'The chosen date is already locked.'|trans({}, 'validators')) }} + {% else %} + {% endif %} {{ form_end(form) }} {% endblock %} {# "table-responsive" does not work, because that would render dropdowns at the bottom behind the container #}