386 lines
15 KiB
PHP
386 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Controller\Booking\Create;
|
|
|
|
use App\Form\BookingApplicantTravelsType;
|
|
use App\Form\BookingApplicantType;
|
|
use App\Form\BookingParticipantType;
|
|
use App\Form\Model\BookingDto;
|
|
use App\Form\Model\ParticipantDto;
|
|
use App\Htmx\HxTrait;
|
|
use App\Service\ApplicantInsuranceCascade;
|
|
use App\Service\BookingConfigurator;
|
|
use App\Service\BookingCreateContextFactory;
|
|
use App\Service\BookingSessionManager;
|
|
use App\Service\ParticipantDataPrefiller;
|
|
use App\Service\ParticipantFormSupport;
|
|
use App\Service\RoomPricingCalculator;
|
|
use App\Service\TravelDataProvider;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\Form\FormInterface;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
/**
|
|
* Handles participant editing and refresh actions within booking step 2.
|
|
*/
|
|
class Step2ParticipantController extends AbstractController
|
|
{
|
|
use HxTrait;
|
|
|
|
public function __construct(
|
|
private readonly BookingConfigurator $bookingService,
|
|
private readonly BookingSessionManager $bookingSessionService,
|
|
private readonly BookingCreateContextFactory $createContextFactory,
|
|
private readonly TravelDataProvider $travelDataService,
|
|
private readonly ParticipantDataPrefiller $prepopulationService,
|
|
private readonly ParticipantFormSupport $participantFormSupportService,
|
|
private readonly ApplicantInsuranceCascade $applicantInsuranceCascade,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Show or submit individual participant form.
|
|
*/
|
|
#[Route(
|
|
path: '/bookings/create/participants/{index}',
|
|
name: 'app_booking_create_step_2_participant',
|
|
requirements: ['index' => '\d+']
|
|
)]
|
|
public function editParticipant(int $index, Request $request): Response
|
|
{
|
|
$result = $this->loadBookingDtoOrRedirect($request);
|
|
if ($result instanceof Response) {
|
|
return $result;
|
|
}
|
|
$bookingDto = $result;
|
|
|
|
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
|
|
|
|
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
|
|
|
|
$form = $this->createParticipantForm($bookingDto, $index);
|
|
|
|
$form->handleRequest($request);
|
|
$isSubmitted = $form->isSubmitted();
|
|
|
|
$isDummyDataFill = $this
|
|
->prepopulationService
|
|
->isDummyDataFillRequested($bookingDto->participants[$index], $bookingDto->getMode())
|
|
;
|
|
if (true === $isSubmitted && true === $isDummyDataFill) {
|
|
$this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index);
|
|
|
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
|
|
$form = $this->createParticipantForm($bookingDto, $index);
|
|
|
|
return $this->renderParticipantForm($form, $index, $bookingDto);
|
|
}
|
|
|
|
if (true === $isSubmitted) {
|
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
|
}
|
|
|
|
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
|
|
|
|
if (true === $isSubmitted && true === $form->isValid()) {
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
$this->addNotificationsAsFlashMessages($notifications);
|
|
|
|
return $this->redirectToRoute('app_booking_create_step_2');
|
|
}
|
|
|
|
// Notifications were already cleared from the DTO above, so they have to be handed
|
|
// to the render path too - otherwise every invalid submission silently swallows them
|
|
return $this->renderParticipantForm($form, $index, $bookingDto, $notifications);
|
|
}
|
|
|
|
/**
|
|
* HTMX refresh endpoint for individual participant form.
|
|
*/
|
|
#[Route(
|
|
path: '/bookings/create/participants/{index}/refresh',
|
|
name: 'app_booking_create_step_2_participant_refresh',
|
|
requirements: ['index' => '\d+'],
|
|
methods: ['POST']
|
|
)]
|
|
public function refreshParticipantForm(int $index, Request $request): Response
|
|
{
|
|
$result = $this->loadBookingDtoOrRedirect($request);
|
|
if ($result instanceof Response) {
|
|
return $result;
|
|
}
|
|
$bookingDto = $result;
|
|
|
|
$this->participantFormSupportService->ensureParticipantExists($bookingDto, $index);
|
|
|
|
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
|
|
|
|
return $this->handleParticipantRefresh(
|
|
$request,
|
|
$bookingDto,
|
|
$index,
|
|
'app_booking_create_step_2_participant_refresh'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Flips the booking-level "I am not travelling myself" choice.
|
|
*
|
|
* Toggling on moves participant[0]'s personal data into the applicant record and leaves
|
|
* behind a blank traveller that keeps only its index and room assignment: the services were
|
|
* derived from an age that no longer applies. Toggling off moves it back.
|
|
*
|
|
* Only offered where the travel date allows it; elsewhere the request changes nothing.
|
|
*/
|
|
#[Route(
|
|
path: '/bookings/create/applicant-toggle',
|
|
name: 'app_booking_create_step_2_applicant_toggle',
|
|
methods: ['POST']
|
|
)]
|
|
public function toggleApplicantTravels(Request $request): Response
|
|
{
|
|
$result = $this->loadBookingDtoOrRedirect($request);
|
|
if ($result instanceof Response) {
|
|
return $result;
|
|
}
|
|
$bookingDto = $result;
|
|
|
|
if (false === $bookingDto->travel->allowsNonTravellingApplicant()) {
|
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
|
}
|
|
|
|
$this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel);
|
|
|
|
// Captured before handleRequest(), which binds the new choice straight onto the DTO.
|
|
$previousChoice = $bookingDto->applicantTravels;
|
|
|
|
$form = $this->createForm(BookingApplicantTravelsType::class, $bookingDto);
|
|
$form->handleRequest($request);
|
|
|
|
if (false === $form->isSubmitted() || false === $form->isValid()) {
|
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
|
}
|
|
|
|
$applicantTravels = $bookingDto->applicantTravels;
|
|
|
|
if ($applicantTravels !== $previousChoice) {
|
|
$this->participantFormSupportService->ensureParticipantExists($bookingDto, 0);
|
|
|
|
if (false === $applicantTravels) {
|
|
$bookingDto->applicant = $bookingDto->participants[0];
|
|
$bookingDto->applicant->index = null;
|
|
$bookingDto->participants[0] = $this->participantFormSupportService
|
|
->createBlankTravellerFrom($bookingDto->applicant);
|
|
} else {
|
|
$bookingDto->restoreTravellingApplicant();
|
|
}
|
|
|
|
// Family insurance is not offered on a split booking, so a choice already made on
|
|
// participant[0] has to go with it.
|
|
if (false === $applicantTravels) {
|
|
$this->participantFormSupportService->clearFamilyInsurance($bookingDto);
|
|
}
|
|
|
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
|
}
|
|
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
|
|
// The choice rebuilds the whole participant list, so navigate rather than swap a fragment.
|
|
return $this->htmxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
|
}
|
|
|
|
/**
|
|
* Contact data of an applicant who does not travel along.
|
|
*
|
|
* Guests only. A logged-in applicant's data comes from their account and is rendered as
|
|
* static text on the step-2 card, because editing it here would create a duplicate BusPro
|
|
* record - the same reason participant[0] is static when the applicant travels.
|
|
*/
|
|
#[Route(
|
|
path: '/bookings/create/applicant',
|
|
name: 'app_booking_create_applicant'
|
|
)]
|
|
public function editApplicant(Request $request): Response
|
|
{
|
|
$result = $this->loadBookingDtoOrRedirect($request);
|
|
if ($result instanceof Response) {
|
|
return $result;
|
|
}
|
|
$bookingDto = $result;
|
|
|
|
if (true === $bookingDto->applicantTravels || false === $bookingDto->travel->allowsNonTravellingApplicant()) {
|
|
return $this->redirectToRoute('app_booking_create_step_2');
|
|
}
|
|
|
|
$bookingDto->applicant ??= new ParticipantDto();
|
|
|
|
$form = $this->createForm(BookingApplicantType::class, $bookingDto->applicant);
|
|
$form->handleRequest($request);
|
|
|
|
// Same `#KUN#` shortcut the participant forms offer: a last name of '#KUN#' fills the
|
|
// whole block with dummy data instead of saving what was typed.
|
|
if ($form->isSubmitted()
|
|
&& $this->prepopulationService->isDummyDataFillRequested($bookingDto->applicant, $bookingDto->getMode())) {
|
|
$this->prepopulationService->fillDummyApplicant($bookingDto->applicant);
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
|
|
return $this->redirectToRoute('app_booking_create_applicant');
|
|
}
|
|
|
|
if ($form->isSubmitted() && $form->isValid()) {
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
|
|
return $this->redirectToRoute('app_booking_create_step_2');
|
|
}
|
|
|
|
// Keep an invalid submission in the session so the entered data survives a reload.
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
|
|
|
$bookingCreateContext = $this->createContextFactory->create(
|
|
$bookingDto,
|
|
RoomPricingCalculator::PRICING_MODE_SELECTION
|
|
);
|
|
|
|
return $this->render('booking/create/applicant.html.twig', [
|
|
'form' => $form,
|
|
'bookingCreateContext' => $bookingCreateContext,
|
|
'bookingDto' => $bookingCreateContext->bookingDto,
|
|
'summaryData' => $bookingCreateContext->summaryData,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param FormInterface<mixed> $form
|
|
* @param array<array{type: string, message: string}> $notifications
|
|
*/
|
|
private function renderParticipantForm(
|
|
FormInterface $form,
|
|
int $index,
|
|
BookingDto $bookingDto,
|
|
array $notifications = [],
|
|
): Response {
|
|
$bookingCreateContext = $this->createContextFactory->create(
|
|
$bookingDto,
|
|
RoomPricingCalculator::PRICING_MODE_SELECTION
|
|
);
|
|
|
|
// The rendered template includes _partials/_flashes.html.twig, so flashes added here
|
|
// are consumed by this very response instead of leaking into the next page
|
|
$this->addNotificationsAsFlashMessages($notifications);
|
|
|
|
return $this->render('booking/create/step_2_participant.html.twig', [
|
|
'form' => $form,
|
|
'participantIndex' => $index,
|
|
'bookingCreateContext' => $bookingCreateContext,
|
|
'bookingDto' => $bookingCreateContext->bookingDto,
|
|
'summaryData' => $bookingCreateContext->summaryData,
|
|
'refreshRouteName' => 'app_booking_create_step_2_participant_refresh',
|
|
]);
|
|
}
|
|
|
|
private function loadBookingDtoOrRedirect(Request $request): BookingDto|Response
|
|
{
|
|
$bookingDto = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
|
|
|
|
if (null === $bookingDto) {
|
|
$this->addFlash('info', 'Deine Sitzung ist abgelaufen. Bitte starte eine neue Buchung.');
|
|
|
|
return $this->redirectToRoute('app_login');
|
|
}
|
|
|
|
return $bookingDto;
|
|
}
|
|
|
|
/** @return FormInterface<mixed> */
|
|
private function createParticipantForm(BookingDto $bookingDto, int $index): FormInterface
|
|
{
|
|
$wrapper = $this->participantFormSupportService->createParticipantEditDto($bookingDto, $index);
|
|
|
|
return $this->createForm(
|
|
BookingParticipantType::class,
|
|
$wrapper,
|
|
$this->participantFormSupportService->getParticipantFormOptions($bookingDto)
|
|
);
|
|
}
|
|
|
|
private function handleParticipantRefresh(
|
|
Request $request,
|
|
BookingDto $bookingDto,
|
|
int $index,
|
|
string $refreshRouteName,
|
|
): Response {
|
|
$form = $this->createForm(
|
|
BookingParticipantType::class,
|
|
$this->participantFormSupportService->createParticipantEditDto($bookingDto, $index),
|
|
$this->participantFormSupportService->getParticipantFormOptions($bookingDto, true)
|
|
);
|
|
|
|
$form->handleRequest($request);
|
|
|
|
$this->bookingService->preselectDefaultServices($bookingDto);
|
|
$this->applicantInsuranceCascade->apply($bookingDto);
|
|
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
|
$this->bookingService->applyCreateBookingStatusRules($bookingDto);
|
|
}
|
|
|
|
$form = $this->createForm(
|
|
BookingParticipantType::class,
|
|
$this->participantFormSupportService->createParticipantEditDto($bookingDto, $index),
|
|
$this->participantFormSupportService->getParticipantFormOptions($bookingDto, true)
|
|
);
|
|
|
|
$notifications = $this->participantFormSupportService->collectAndClearNotifications($bookingDto);
|
|
|
|
$this->bookingSessionService->saveBookingDto($request, $bookingDto, $bookingDto->getMode());
|
|
|
|
$bookingCreateContext = $this->createContextFactory->create(
|
|
$bookingDto,
|
|
RoomPricingCalculator::PRICING_MODE_SELECTION
|
|
);
|
|
|
|
$response = $this->htmxOobResponse(
|
|
'booking/_participant_form.html.twig',
|
|
['participant_form', 'booking_summary'],
|
|
[
|
|
'form' => $form->createView(),
|
|
'participantIndex' => $index,
|
|
'bookingCreateContext' => $bookingCreateContext,
|
|
'bookingDto' => $bookingCreateContext->bookingDto,
|
|
'summaryData' => $bookingCreateContext->summaryData,
|
|
'refreshRouteName' => $refreshRouteName,
|
|
]
|
|
);
|
|
|
|
if (false === empty($notifications)) {
|
|
$response->headers->set('HX-Trigger', json_encode([
|
|
'showNotifications' => $notifications,
|
|
]));
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* @param array<array{type: string, message: string}> $notifications
|
|
*/
|
|
private function addNotificationsAsFlashMessages(array $notifications): void
|
|
{
|
|
foreach ($notifications as $notification) {
|
|
$this->addFlash($notification['type'], $notification['message']);
|
|
}
|
|
}
|
|
}
|