From 6feb8178b5d313b1005c322b4b322c7404a69cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 18 Jul 2025 15:40:23 +0200 Subject: [PATCH] wip: booking process, room assignment --- src/Controller/Booking/BookingCreateTrait.php | 56 ++++++++ src/Controller/Booking/CreateController.php | 132 ------------------ .../Booking/CreateStep1Controller.php | 60 ++++++++ .../Booking/CreateStep2Controller.php | 113 +++++++++++++++ .../Booking/CreateStep3Controller.php | 43 ++++++ src/Form/BookingCreateParticipantType.php | 13 ++ src/Form/BookingCreateStep2Type.php | 118 +++++++++++++++- src/Form/Model/BookingCreateDto.php | 42 +----- src/Form/Model/ParticipantDto.php | 4 + src/Service/BookingService.php | 14 ++ templates/booking/create_step_1.html.twig | 4 +- templates/booking/create_step_2.html.twig | 56 +++++--- 12 files changed, 454 insertions(+), 201 deletions(-) create mode 100644 src/Controller/Booking/BookingCreateTrait.php delete mode 100644 src/Controller/Booking/CreateController.php create mode 100644 src/Controller/Booking/CreateStep1Controller.php create mode 100644 src/Controller/Booking/CreateStep2Controller.php create mode 100644 src/Controller/Booking/CreateStep3Controller.php diff --git a/src/Controller/Booking/BookingCreateTrait.php b/src/Controller/Booking/BookingCreateTrait.php new file mode 100644 index 0000000..1ee5570 --- /dev/null +++ b/src/Controller/Booking/BookingCreateTrait.php @@ -0,0 +1,56 @@ + $bookingCreateDto->currentStep) { + $this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.'); + + $this->redirectToCurrentStep($bookingCreateDto); + } + } + + /** + * Redirects to the current step based on the DTO's currentStep. + * + * @param BookingCreateDto $bookingCreateDto + */ + private function redirectToCurrentStep(BookingCreateDto $bookingCreateDto): RedirectResponse + { + $routeParams = [ + 'date_id' => $bookingCreateDto->travelData->id, + 'hotel_id' => $bookingCreateDto->travelData->hotelId, + ]; + + $route = match ($bookingCreateDto->currentStep) { + 1 => 'app_booking_create_step_1', + 2 => 'app_booking_create_step_2', + 3 => 'app_booking_create_step_3', + default => 'app_booking_create_step_1', + }; + + return $this->redirectToRoute($route, $routeParams); + } +} \ No newline at end of file diff --git a/src/Controller/Booking/CreateController.php b/src/Controller/Booking/CreateController.php deleted file mode 100644 index 88bbf19..0000000 --- a/src/Controller/Booking/CreateController.php +++ /dev/null @@ -1,132 +0,0 @@ -bookingCreateService->getOrCreateBookingCreateDto($request); - - // Validate step access - allow step 1 or redirect to current step - $this->validateStepAccess($bookingCreateDto, 1); - - $form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [ - 'validation_groups' => ['booking_create_step_1'], - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - $bookingCreateDto->currentStep = 2; - $this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto); - - return $this->redirectToRoute('app_booking_create_step_2'); - } - - return $this->render('booking/create_step_1.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ]); - } - - #[Route('/bookings/create/participants', name: 'app_booking_create_step_2')] - public function participants(Request $request): Response - { - $bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request); - - // Validate step access - $this->validateStepAccess($bookingCreateDto, 2); - - // Ensure correct number of participants - if ($bookingCreateDto->getParticipantsCount() !== count($bookingCreateDto->participants)) { - $participants = $bookingCreateDto->participants; - $bookingCreateDto->participants = []; - for ($i = 0; $i < $bookingCreateDto->getParticipantsCount(); ++$i) { - $bookingCreateDto->participants[] = $participants[$i] ?? new ParticipantDto(); - } - } - - $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'attr' => ['novalidate' => 'novalidate'], - 'validation_groups' => ['booking_create_step_2'], - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - $bookingCreateDto->currentStep = 3; - $this->bookingCreateService->saveBookingCreateDto($request, $bookingCreateDto); - - return $this->redirectToRoute('app_booking_create_step_3'); - } - - return $this->render('booking/create_step_2.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ]); - } - - #[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')] - public function confirm(Request $request): Response - { - $bookingCreateDto = $this->bookingCreateService->getOrCreateBookingCreateDto($request); - - // Validate step access - $this->validateStepAccess($bookingCreateDto, 3); - - return $this->render('booking/confirm.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - ]); - } - - /** - * Validates step access and redirects if necessary. - * - * @param \App\Form\Model\BookingCreateDto $bookingCreateDto - */ - private function validateStepAccess($bookingCreateDto, int $expectedStep): void - { - // Allow access to current step or any previous step - if ($expectedStep > $bookingCreateDto->currentStep) { - $this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.'); - - $this->redirectToCurrentStep($bookingCreateDto); - } - } - - /** - * Redirects to the current step based on the DTO's currentStep. - */ - private function redirectToCurrentStep($bookingCreateDto): void - { - $routeParams = [ - 'date_id' => $bookingCreateDto->travelData->id, - 'hotel_id' => $bookingCreateDto->travelData->hotelId, - ]; - - $route = match ($bookingCreateDto->currentStep) { - 1 => 'app_booking_create_step_1', - 2 => 'app_booking_create_step_2', - 3 => 'app_booking_create_step_3', - default => 'app_booking_create_step_1', - }; - - $this->redirectToRoute($route, $routeParams); - } -} diff --git a/src/Controller/Booking/CreateStep1Controller.php b/src/Controller/Booking/CreateStep1Controller.php new file mode 100644 index 0000000..a98a32b --- /dev/null +++ b/src/Controller/Booking/CreateStep1Controller.php @@ -0,0 +1,60 @@ +bookingService->getOrCreateBookingCreateDto($request); + $participantsCount = $this->bookingService->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travelData); + + // Validate step access - allow step 1 or redirect to current step + $this->validateStepAccess($bookingCreateDto, 1); + + $form = $this->createForm(BookingCreateStep1Type::class, $bookingCreateDto, [ + 'validation_groups' => ['booking_create_step_1'], + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $bookingCreateDto->currentStep = 2; + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + return $this->redirectToRoute('app_booking_create_step_2'); + } + + return $this->render('booking/create_step_1.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'participantsCount' => $participantsCount, + 'form' => $form->createView(), + ]); + } +} diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php new file mode 100644 index 0000000..f4cd34b --- /dev/null +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -0,0 +1,113 @@ +bookingService + ->getOrCreateBookingCreateDto($request); + + // Validate step access + $this->validateStepAccess($bookingCreateDto, 2); + + // Ensure correct number of participants + $this->ensureCorrectNumberOfParticipants($bookingCreateDto); + $participantsCount = $this->getParticipantsCount($bookingCreateDto); + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ + 'attr' => ['novalidate' => 'novalidate'], + 'validation_groups' => ['booking_create_step_2'], + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $bookingCreateDto->currentStep = 3; + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + return $this->redirectToRoute('app_booking_create_step_3'); + } + + return $this->render('booking/create_step_2.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + 'participantsCount' => $participantsCount, + 'form' => $form->createView(), + ]); + } + + /** + * Handles HTMX requests for dynamic form updates when room selections change. + */ + #[Route('/bookings/create/participants/refresh', name: 'app_booking_create_step_2_refresh', methods: ['POST'])] + public function refreshParticipantForm(Request $request): Response + { + $bookingCreateDto = $this->bookingService->getOrCreateBookingCreateDto($request); + + // Process form data without validation to capture current state + $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ + 'attr' => ['novalidate' => 'novalidate'], + 'validation_groups' => false, + ]); + + $form->handleRequest($request); + + $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + + return $this->renderBlock('booking/create_step_2.html.twig', 'participants_form', [ + 'form' => $form->createView(), + ]); + } + + private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int + { + return $this + ->bookingService + ->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travelData); + } + + private function ensureCorrectNumberOfParticipants(BookingCreateDto $bookingCreateDto): void + { + $participantsCount = $this->getParticipantsCount($bookingCreateDto); + + $participants = $bookingCreateDto->participants; + $bookingCreateDto->participants = []; + for ($i = 0; $i < $participantsCount; ++$i) { + $participant = $participants[$i] ?? new ParticipantDto(); + $participant->index = $i; + $bookingCreateDto->participants[$i] = $participant; + } + } +} diff --git a/src/Controller/Booking/CreateStep3Controller.php b/src/Controller/Booking/CreateStep3Controller.php new file mode 100644 index 0000000..fe3b495 --- /dev/null +++ b/src/Controller/Booking/CreateStep3Controller.php @@ -0,0 +1,43 @@ +bookingCreateService->getOrCreateBookingCreateDto($request); + + // Validate step access + $this->validateStepAccess($bookingCreateDto, 3); + + return $this->render('booking/confirm.html.twig', [ + 'bookingCreateDto' => $bookingCreateDto, + ]); + } +} diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index aedc6ca..cf0f643 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -10,6 +10,8 @@ use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateParticipantType extends AbstractType @@ -52,6 +54,16 @@ class BookingCreateParticipantType extends AbstractType 'label' => 'Telefon (mobil)', 'required' => false, ]) + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { + $data = $event->getData(); + $form = $event->getForm(); + + $form->add('assignedRoomId', ChoiceType::class, [ + 'label' => 'Zimmer', + 'placeholder' => 'Bitte wählen', + 'choices' => $options['room_choices'][$data->index], + ]); + }) ; } @@ -59,6 +71,7 @@ class BookingCreateParticipantType extends AbstractType { $resolver->setDefaults([ 'data_class' => ParticipantDto::class, + 'room_choices' => [], ]); } } diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 7238393..dd14fdf 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -6,19 +6,123 @@ use App\Form\Model\BookingCreateDto; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateStep2Type extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder - ->add('participants', CollectionType::class, [ - 'entry_type' => BookingCreateParticipantType::class, - 'allow_add' => false, - 'allow_delete' => false, - 'by_reference' => false, - ]); + $builder->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']); + $builder->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']); + } + + public function onPreSetData(FormEvent $event): void + { + /** @var BookingCreateDto $bookingCreateDto */ + $bookingCreateDto = $event->getData(); + $form = $event->getForm(); + + if (null === $bookingCreateDto) { + return; + } + + // Count participants assigned to each room + $roomOccupancy = []; + foreach ($bookingCreateDto->participants as $participant) { + if (null !== $participant->assignedRoomId) { + $roomOccupancy[$participant->assignedRoomId] = ($roomOccupancy[$participant->assignedRoomId] ?? 0) + 1; + } + } + + // Generate per-participant room choices + $roomChoices = []; + foreach ($bookingCreateDto->participants as $index => $participant) { + $participantRoomChoices = []; + + foreach ($bookingCreateDto->getSelectedRooms() as $roomSelection) { + $currentOccupancy = $roomOccupancy[$roomSelection->roomId] ?? 0; + + // If this participant is already assigned to this room, exclude them from occupancy count + $adjustedOccupancy = $currentOccupancy; + if ($participant->assignedRoomId === $roomSelection->roomId) { + --$adjustedOccupancy; + } + + $remainingCapacity = $roomSelection->minPax - $adjustedOccupancy; + + // Include room if it has capacity OR if it's the participant's current assignment + if ($remainingCapacity > 0 || $participant->assignedRoomId === $roomSelection->roomId) { + $participantRoomChoices[$roomSelection->roomLabel] = $roomSelection->roomId; + } + } + + $roomChoices[$index] = $participantRoomChoices; + } + + // Create a custom form type that handles per-participant room choices + $form->add('participants', CollectionType::class, [ + 'entry_type' => BookingCreateParticipantType::class, + 'allow_add' => false, + 'allow_delete' => false, + 'by_reference' => false, + 'entry_options' => [ + 'room_choices' => $roomChoices, + ], + ]); + } + + public function onPreSubmit(FormEvent $event): void + { + $data = $event->getData(); + $form = $event->getForm(); + /** @var BookingCreateDto $bookingCreateDto */ + $bookingCreateDto = $form->getData(); + + $roomOccupancy = []; + foreach ($data['participants'] as $participant) { + if (null !== $participant['assignedRoomId']) { + $assignedRoomId = (int) $participant['assignedRoomId']; + $roomOccupancy[$assignedRoomId] = ($roomOccupancy[$assignedRoomId] ?? 0) + 1; + } + } + + $roomChoices = []; + foreach ($data['participants'] as $index => $participant) { + $participantRoomChoices = []; + $assignedRoomId = (int) $participant['assignedRoomId']; + + foreach ($bookingCreateDto->getSelectedRooms() as $roomSelection) { + $currentOccupancy = $roomOccupancy[$roomSelection->roomId] ?? 0; + + // If this participant is already assigned to this room, exclude them from occupancy count + $adjustedOccupancy = $currentOccupancy; + if ($assignedRoomId === $roomSelection->roomId) { + --$adjustedOccupancy; + } + + $remainingCapacity = $roomSelection->minPax - $adjustedOccupancy; + + // Include room if it has capacity OR if it's the participant's current assignment + if ($remainingCapacity > 0 || $assignedRoomId === $roomSelection->roomId) { + $participantRoomChoices[$roomSelection->roomLabel] = $roomSelection->roomId; + } + } + + $roomChoices[$index] = $participantRoomChoices; + } + $form->remove('participants'); + $form->add('participants', CollectionType::class, [ + 'entry_type' => BookingCreateParticipantType::class, + 'allow_add' => false, + 'allow_delete' => false, + 'by_reference' => false, + 'entry_options' => [ + 'room_choices' => $roomChoices, + ], + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/Model/BookingCreateDto.php b/src/Form/Model/BookingCreateDto.php index ba0eb05..727ff99 100644 --- a/src/Form/Model/BookingCreateDto.php +++ b/src/Form/Model/BookingCreateDto.php @@ -4,16 +4,15 @@ namespace App\Form\Model; use App\BusProNet\Model\Travel; use Symfony\Component\Validator\Constraints as Assert; -use Symfony\Component\Validator\Context\ExecutionContextInterface; class BookingCreateDto { public int $currentStep = 1; - #[Assert\Valid] /** * @var array */ + #[Assert\Valid] public array $roomSelections = []; /** @@ -26,42 +25,9 @@ class BookingCreateDto { } - #[Assert\Callback(groups: ['booking_create_step_1'])] - public function assertValidRoomSelections(ExecutionContextInterface $context): void - { - $participantsCount = $this->getParticipantsCount(); - $selectedContingent = 0; - - foreach ($this->roomSelections as $roomSelection) { - $selectedContingent += $roomSelection->quantity * $roomSelection->minPax; - } - - if (0 === $selectedContingent) { - $context->buildViolation('Bitte mindestens ein Zimmer auswählen.') - ->atPath('roomSelections') - ->addViolation(); - } - - if ($selectedContingent !== $participantsCount) { - $context->buildViolation('Die ausgewählten Zimmer passen nicht zur Teilnehmerzahl.') - ->atPath('roomSelections') - ->addViolation(); - } - } - - public function getParticipantsCount(): int - { - $participantsCount = 0; - $rooms = $this->travelData->getAvailableRooms(); - - foreach ($this->roomSelections as $roomSelection) { - $room = $rooms[$roomSelection->roomId]; - $participantsCount += $room->minPax * $roomSelection->quantity; - } - - return $participantsCount; - } - + /** + * @return array + */ public function getSelectedRooms(): array { return array_filter($this->roomSelections, function (RoomSelectionDto $roomSelection) { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index a8245ab..ce1813e 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -37,6 +37,10 @@ class ParticipantDto public ?string $email = null; public ?string $mobile = null; + + #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])] + public ?int $assignedRoomId = null; + public array $courses = []; public array $additionalServices = []; public array $skiPass = []; diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index c61bf5d..82b795b 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -3,6 +3,7 @@ namespace App\Service; use App\BusProNet\Model\Room; +use App\BusProNet\Model\Travel; use App\Form\Model\BookingCreateDto; use App\Form\Model\RoomSelectionDto; use App\Service\TravelDataService; @@ -88,4 +89,17 @@ class BookingService return array_map('intval', array_filter($rooms, 'strlen')); } + + public function getParticipantsCount(array $roomSelections, Travel $travelData): int + { + $participantsCount = 0; + $rooms = $travelData->getAvailableRooms(); + + foreach ($roomSelections as $roomSelection) { + $room = $rooms[$roomSelection->roomId]; + $participantsCount += $room->minPax * $roomSelection->quantity; + } + + return $participantsCount; + } } diff --git a/templates/booking/create_step_1.html.twig b/templates/booking/create_step_1.html.twig index 6afd5f3..cd93a8d 100644 --- a/templates/booking/create_step_1.html.twig +++ b/templates/booking/create_step_1.html.twig @@ -19,8 +19,8 @@

Reise: {{ bookingCreateDto.travelData.label }}

Datum: {{ bookingCreateDto.travelData.dateFrom | date('d.m.Y') }} - {{ bookingCreateDto.travelData.dateTo | date('d.m.Y') }}

Hotel: {{ bookingCreateDto.travelData.hotel.name }}

- {% if bookingCreateDto.participantsCount > 0 %} -

Teilnehmerzahl: {{ bookingCreateDto.participantsCount }}

+ {% if participantsCount > 0 %} +

Teilnehmerzahl: {{ participantsCount }}

{% endif %} diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index b05785d..77d109e 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -8,26 +8,38 @@

Teilnehmer

{{ form_start(form) }} {% do form.participants.setRendered %} -
- {% for participant in form.participants %} -
- - Teilnehmer:in {{ loop.index }} - -
- {{ form_row(participant.firstName) }} - {{ form_row(participant.lastName) }} - {{ form_row(participant.dateOfBirth) }} - {{ form_row(participant.gender) }} - {{ form_row(participant.nationality) }} -
-
- {{ form_row(participant.email) }} - {{ form_row(participant.mobile) }} -
-
- {% endfor %} -
+ {% block participants_form %} +
+ {% for participant in form.participants %} +
+ + Teilnehmer:in {{ loop.index }} + +
+ {{ form_row(participant.firstName) }} + {{ form_row(participant.lastName) }} + {{ form_row(participant.dateOfBirth) }} + {{ form_row(participant.gender) }} + {{ form_row(participant.nationality) }} +
+
+ {{ form_row(participant.email) }} + {{ form_row(participant.mobile) }} +
+
+ {{ form_row(participant.assignedRoomId, { + 'attr': { + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-target': '#participants-form', + 'hx-select': '#participants-form', + 'hx-swap': 'outerHTML' + } + }) }} +
+
+ {% endfor %} +
+ {% endblock %}
Zurück @@ -55,12 +67,12 @@

{{ bookingCreateDto.travelData.hotel.name }}

- {% if bookingCreateDto.participantsCount > 0 %} + {% if participantsCount > 0 %}

Anzahl Teilnehmer

- {{ bookingCreateDto.participantsCount }} + {{ participantsCount }}

{% endif %}