diff --git a/src/Controller/Admin/AccommodationBooking/CreateController.php b/src/Controller/Admin/AccommodationBooking/CreateController.php index e8f1baf..b38b9e0 100644 --- a/src/Controller/Admin/AccommodationBooking/CreateController.php +++ b/src/Controller/Admin/AccommodationBooking/CreateController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Admin\AccommodationBooking; use App\Entity\Groups\AccommodationBooking; +use App\Entity\User; use App\Form\Admin\Groups\AccommodationBookingCreateType; use App\Htmx\HxRedirectResponse; use App\Service\AccommodationBookingService; @@ -31,6 +32,14 @@ class CreateController extends AbstractController { $booking = new AccommodationBooking(); + // Whoever creates a booking in the backoffice looks after it until someone else is + // assigned in the edit form. The route is behind ROLE_GROUPS_MANAGER, so the creator + // is always eligible as Betreuer. + $user = $this->getUser(); + if ($user instanceof User) { + $booking->setManagedBy($user); + } + $form = $this->createForm(AccommodationBookingCreateType::class, $booking, [ 'hx_post' => $request->getRequestUri(), ]); diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index cf5d141..25e8ed8 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -11,6 +11,7 @@ use App\Enum\Groups\AccommodationBookingStatus; use App\Form\Admin\Groups\AccommodationBookingType; use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\BoardServiceRepository; +use App\Repository\UserRepository; use App\Service\AccommodationBookingService; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -28,6 +29,7 @@ class EditController extends AbstractController private readonly LoggerInterface $logger, private readonly BoardServiceRepository $boardServiceRepo, private readonly AdditionalServiceRepository $additionalServiceRepo, + private readonly UserRepository $userRepo, private readonly AccommodationBookingService $bookingService, ) { } @@ -69,12 +71,26 @@ class EditController extends AbstractController )); } + // ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both. + $assignableManagers = []; + if ($this->isGranted('ROLE_GROUPS_ADMIN')) { + $assignableManagers = $this->userRepo->findGroupsStaff(); + + // A user assigned earlier may have lost the role since — keep them in the choices + // so the field can render the current value instead of failing to transform it. + $currentManager = $booking->getManagedBy(); + if (null !== $currentManager && !in_array($currentManager, $assignableManagers, true)) { + $assignableManagers[] = $currentManager; + } + } + $form = $this->createForm(AccommodationBookingType::class, $booking, [ 'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0, 'board_services' => $boardServices, 'additional_services' => $additionalServices, 'current_board_service' => $currentBoardService, 'current_additional_services' => $currentAdditionalServices, + 'assignable_managers' => $assignableManagers, ]); $previousStatus = $booking->getStatus(); diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php index 9b69c96..e83d125 100644 --- a/src/Controller/Admin/AccommodationBooking/IndexController.php +++ b/src/Controller/Admin/AccommodationBooking/IndexController.php @@ -29,7 +29,9 @@ class IndexController extends AbstractController $qb = $this ->bookingRepository ->createQueryBuilder('booking') + ->select('booking', 'managed_by') ->leftJoin('booking.accommodation', 'accommodation') + ->leftJoin('booking.managedBy', 'managed_by') ->addSelect('accommodation') ->where('booking.dateTo >= :today') ->setParameter('today', $today) diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php index 0d7c36e..6b6689a 100644 --- a/src/Form/Admin/Groups/AccommodationBookingType.php +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -7,6 +7,7 @@ namespace App\Form\Admin\Groups; use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; +use App\Entity\User; use App\Enum\Groups\AccommodationBookingStatus; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; @@ -121,6 +122,20 @@ class AccommodationBookingType extends AbstractType ]); } + // Assigning a manager is reserved for the groups admins; the controller leaves the + // choices empty for everyone else, so the field never enters the form tree and a + // plain manager cannot set it by hand-crafting the request either. + if (!empty($options['assignable_managers'])) { + $builder->add('managedBy', EntityType::class, [ + 'class' => User::class, + 'required' => false, + 'choices' => $options['assignable_managers'], + 'choice_label' => 'email', + 'placeholder' => 'keine Zuordnung', + 'label' => 'Bearbeiter:in', + ]); + } + $builder ->add('status', EnumType::class, [ 'label' => 'Status', @@ -176,11 +191,13 @@ class AccommodationBookingType extends AbstractType 'additional_services' => [], 'current_board_service' => null, 'current_additional_services' => [], + 'assignable_managers' => [], ]); $resolver->setAllowedTypes('max_adolescent_age', 'int'); $resolver->setAllowedTypes('board_services', 'array'); $resolver->setAllowedTypes('additional_services', 'array'); $resolver->setAllowedTypes('current_board_service', ['null', BoardService::class]); $resolver->setAllowedTypes('current_additional_services', 'array'); + $resolver->setAllowedTypes('assignable_managers', 'array'); } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index e85e4bf..63e1e6f 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -17,4 +17,22 @@ class UserRepository extends ServiceEntityRepository { parent::__construct($registry, User::class); } + + /** + * Users who can look after a group booking. The roles are a JSON array column and no + * JSON_CONTAINS is registered, so the needles carry their quotes to stay anchored to + * whole array entries. Only the literal roles count — a plain ROLE_ADMIN is not offered. + * + * @return User[] + */ + public function findGroupsStaff(): array + { + return $this->createQueryBuilder('u') + ->andWhere('u.roles LIKE :groupsAdmin OR u.roles LIKE :groupsManager') + ->setParameter('groupsAdmin', '%"ROLE_GROUPS_ADMIN"%') + ->setParameter('groupsManager', '%"ROLE_GROUPS_MANAGER"%') + ->orderBy('u.email', 'ASC') + ->getQuery() + ->getResult(); + } } diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index d1a1fc5..249be02 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -47,6 +47,11 @@ Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}

{% endif %} + {% if form.managedBy is defined %} +
+ {{ form_row(form.managedBy) }} +
+ {% endif %} {{ form_row(form.accommodationDiscount) }} {{ form_row(form.boardServiceDiscount) }} {{ form_row(form.additionalServicesDiscount) }} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index f09f5c5..93259c1 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -22,6 +22,9 @@ Personen + + Betreuer:in + Status @@ -49,6 +52,13 @@ {{ booking.paxCount }} + + {% if booking.managedBy is not null %} + {{ booking.managedBy.email }} + {% else %} + keine Zuordnung + {% endif %} + {{ booking.status.label }} diff --git a/tests/Controller/Admin/AccommodationBooking/CreateControllerTest.php b/tests/Controller/Admin/AccommodationBooking/CreateControllerTest.php new file mode 100644 index 0000000..ce733f0 --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/CreateControllerTest.php @@ -0,0 +1,86 @@ +buildController($user); + $controller->index(Request::create('/admin/accommodation-booking/create')); + + self::assertSame($user, $controller->capturedBooking?->getManagedBy()); + } + + private function buildController(?UserInterface $user): TestableCreateController + { + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturn($form); + $form->method('isSubmitted')->willReturn(false); + + return new TestableCreateController( + $this->createMock(EntityManagerInterface::class), + $this->createMock(LoggerInterface::class), + $this->createMock(AccommodationBookingService::class), + $form, + $user, + ); + } +} + +final class TestableCreateController extends CreateController +{ + public ?AccommodationBooking $capturedBooking = null; + + public function __construct( + EntityManagerInterface $entityManager, + LoggerInterface $logger, + AccommodationBookingService $bookingService, + private readonly FormInterface $form, + private readonly ?UserInterface $user, + ) { + parent::__construct($entityManager, $logger, $bookingService); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + if ($data instanceof AccommodationBooking) { + $this->capturedBooking = $data; + } + + return $this->form; + } + + protected function getUser(): ?UserInterface + { + return $this->user; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response(); + } +} diff --git a/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php index 0969fdb..82c132f 100644 --- a/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php +++ b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php @@ -7,8 +7,10 @@ namespace App\Tests\Controller\Admin\AccommodationBooking; use App\Controller\Admin\AccommodationBooking\EditController; use App\Entity\Groups\AccommodationBooking; use App\Enum\Groups\AccommodationBookingStatus; +use App\Entity\User; use App\Repository\Groups\AdditionalServiceRepository; use App\Repository\Groups\BoardServiceRepository; +use App\Repository\UserRepository; use App\Service\AccommodationBookingService; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -101,6 +103,7 @@ class EditControllerTest extends TestCase $this->createMock(LoggerInterface::class), $this->createMock(BoardServiceRepository::class), $this->createMock(AdditionalServiceRepository::class), + $this->createMock(UserRepository::class), $bookingService, $form, ); @@ -109,26 +112,106 @@ class EditControllerTest extends TestCase self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); } + + public function testManagerWithoutAdminRoleGetsNoBetreuerChoices(): void + { + $userRepo = $this->createMock(UserRepository::class); + $userRepo->expects(self::never())->method('findGroupsStaff'); + + $controller = $this->buildController($userRepo, granted: false); + $controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit')); + + self::assertSame([], $controller->capturedOptions['assignable_managers']); + } + + public function testAdminGetsTheGroupsStaffAsBetreuerChoices(): void + { + $staff = [new User('manager@example.test')]; + $userRepo = $this->createMock(UserRepository::class); + $userRepo->method('findGroupsStaff')->willReturn($staff); + + $controller = $this->buildController($userRepo, granted: true); + $controller->index(new AccommodationBooking(), Request::create('/admin/accommodation-booking/1/edit')); + + self::assertSame($staff, $controller->capturedOptions['assignable_managers']); + } + + public function testAnAssignedUserWhoLostTheRoleStaysSelectable(): void + { + $formerManager = new User('former@example.test'); + $staff = [new User('manager@example.test')]; + + $userRepo = $this->createMock(UserRepository::class); + $userRepo->method('findGroupsStaff')->willReturn($staff); + + $booking = new AccommodationBooking(); + $booking->setManagedBy($formerManager); + + $controller = $this->buildController($userRepo, granted: true); + $controller->index($booking, Request::create('/admin/accommodation-booking/1/edit')); + + self::assertSame([$staff[0], $formerManager], $controller->capturedOptions['assignable_managers']); + } + + private function buildController(UserRepository $userRepo, bool $granted): TestableEditController + { + // An unsubmitted form: index() falls through to render() and only the options + // handed to createForm() are of interest here. + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturn($form); + $form->method('isSubmitted')->willReturn(false); + + return new TestableEditController( + $this->createMock(EntityManagerInterface::class), + $this->createMock(LoggerInterface::class), + $this->createMock(BoardServiceRepository::class), + $this->createMock(AdditionalServiceRepository::class), + $userRepo, + $this->createMock(AccommodationBookingService::class), + $form, + $granted, + ); + } } final class TestableEditController extends EditController { + /** @var array */ + public array $capturedOptions = []; + public function __construct( EntityManagerInterface $entityManager, LoggerInterface $logger, BoardServiceRepository $boardServiceRepo, AdditionalServiceRepository $additionalServiceRepo, + UserRepository $userRepo, AccommodationBookingService $bookingService, private readonly FormInterface $form, + private readonly bool $granted = false, ) { - parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $bookingService); + parent::__construct($entityManager, $logger, $boardServiceRepo, $additionalServiceRepo, $userRepo, $bookingService); } protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface { + $this->capturedOptions = $options; + return $this->form; } + protected function isGranted(mixed $attribute, mixed $subject = null): bool + { + return $this->granted; + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + return new Response(); + } + protected function addFlash(string $type, mixed $message): void { }