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