From 0e0ce254a9ed9d555c4fa2f30d7f1dfb45aac8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 2 Sep 2025 16:48:54 +0200 Subject: [PATCH] wip: show pricing --- .../Booking/CreateStep1Controller.php | 2 + .../Booking/CreateStep2Controller.php | 4 + src/Form/BookingCreateParticipantType.php | 199 ++++++------ src/Form/BookingCreateStep2Type.php | 1 + src/Form/BookingEditParticipantType.php | 27 +- src/Form/RoomSelectType.php | 12 +- .../Abstract/AbstractFieldOptionsProvider.php | 5 +- .../FieldOptionsProviderInterface.php | 3 +- ...ticipantAdditionalServicesFieldHandler.php | 6 +- .../Service/ParticipantBoardFieldHandler.php | 6 +- .../ParticipantCoursesFieldHandler.php | 6 +- .../ParticipantFieldHandlerRegistry.php | 7 +- .../ParticipantFieldOptionsProvider.php | 44 ++- .../ParticipantRentalsFieldHandler.php | 6 +- src/Service/BookingPriceCalculatorService.php | 290 ++++++++++++++++++ src/Service/BookingService.php | 7 +- templates/booking/_summary.html.twig | 104 +++++-- templates/booking/create_step_2.html.twig | 44 ++- templates/forms.html.twig | 12 +- 19 files changed, 602 insertions(+), 183 deletions(-) create mode 100644 src/Service/BookingPriceCalculatorService.php diff --git a/src/Controller/Booking/CreateStep1Controller.php b/src/Controller/Booking/CreateStep1Controller.php index 0bd4035..f225675 100644 --- a/src/Controller/Booking/CreateStep1Controller.php +++ b/src/Controller/Booking/CreateStep1Controller.php @@ -70,6 +70,7 @@ class CreateStep1Controller extends AbstractController 'bookingCreateDto' => $bookingCreateDto, 'roomSummary' => $summary['selectedRooms'], 'participantCount' => $summary['participantCount'], + 'pricingData' => $summary['pricing'], 'form' => $form->createView(), 'groupedRooms' => $groupedRooms, 'groupedSelectedRooms' => $groupedSelectedRooms, @@ -98,6 +99,7 @@ class CreateStep1Controller extends AbstractController 'bookingCreateDto' => $bookingCreateDto, 'roomSummary' => $summary['selectedRooms'], 'participantCount' => $summary['participantCount'], + 'pricingData' => $summary['pricing'], 'groupedSelectedRooms' => $groupedSelectedRooms, ]); } diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php index 11dc515..0430bf1 100644 --- a/src/Controller/Booking/CreateStep2Controller.php +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -62,6 +62,7 @@ class CreateStep2Controller extends AbstractController return $this->redirectToRoute('app_booking_create_step_3'); } + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms); @@ -69,6 +70,7 @@ class CreateStep2Controller extends AbstractController return $this->render('booking/create_step_2.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'participantsCount' => $participantsCount, + 'pricingData' => $summary['pricing'], 'assignmentCounts' => $roomAssignmentCounts, 'form' => $form->createView(), 'groupedSelectedRooms' => $groupedSelectedRooms, @@ -94,6 +96,7 @@ class CreateStep2Controller extends AbstractController $form->handleRequest($request); $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms); @@ -107,6 +110,7 @@ class CreateStep2Controller extends AbstractController 'form' => $form->createView(), 'bookingCreateDto' => $bookingCreateDto, 'participantsCount' => $participantsCount, + 'pricingData' => $summary['pricing'], 'assignmentCounts' => $roomAssignmentCounts, 'groupedSelectedRooms' => $groupedSelectedRooms, ] diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index a9a9f5b..f05aa59 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -30,54 +30,18 @@ class BookingCreateParticipantType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('firstName', TextType::class, [ - 'label' => 'Vorname', - 'clean_xss' => true, - ]) - ->add('lastName', TextType::class, [ - 'label' => 'Nachname', - 'clean_xss' => true, - ]) - ->add('dateOfBirth', BirthdayType::class, [ - 'label' => 'Geburtsdatum', - 'html5' => true, - 'widget' => 'single_text', - 'input' => 'datetime_immutable', - ]) - ->add('gender', ChoiceType::class, [ - 'label' => 'Geschlecht', - 'required' => false, - 'placeholder' => 'keine Angabe', - 'choices' => [ - 'männlich' => 'M', - 'weiblich' => 'W', - 'divers' => 'D', - ], - ]) - ->add('nationality', CountryType::class, [ - 'label' => 'Nationalität', - 'property' => 'nationality', - 'preferred_choices' => ['D', 'A', 'CH'], - ]) - ->add('email', EmailType::class, [ - 'label' => 'E-Mail', - 'required' => false, - 'clean_xss' => true, - ]) - ->add('mobile', TextType::class, [ - 'label' => 'Telefon (mobil)', - 'required' => false, - 'clean_xss' => true, - ]) - ->add('bodyDimensions', BodyDimensionsType::class) - ->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']) - ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']); + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $this->onPreSetData($event); + }) + ->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) { + $this->onPreSubmit($event); + }); } /** * Adds dynamic fields to the form based on participant data. */ - public function onPreSetData(FormEvent $event): void + private function onPreSetData(FormEvent $event): void { /** @var ParticipantDto|null $participantData */ $participantData = $event->getData(); @@ -94,14 +58,17 @@ class BookingCreateParticipantType extends AbstractType return; } + // Add base fields with states applied + $this->addBaseFields($form, $bookingDto, $participantData->index); + + // Add dynamic fields $this->addDynamicFields($form, $bookingDto, $participantData->index); - $this->applyFieldStates($form, $bookingDto, $participantData->index); } /** * Handles form pre-submit events to update field states based on submitted data. */ - public function onPreSubmit(FormEvent $event): void + private function onPreSubmit(FormEvent $event): void { $submittedData = $event->getData(); $form = $event->getForm(); @@ -123,59 +90,71 @@ class BookingCreateParticipantType extends AbstractType return; } - // Apply updated field states based on submitted data - $this->applyFieldStates($form, $bookingDto, $participantData->index, $submittedData); + // Rebuild all fields with updated states based on submitted data + $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData); } /** - * Applies dynamic field states to all form fields. - * - * This method checks each form field for state conditions and applies - * the appropriate state modifications (readonly, disabled, etc.). - * Fields that should be hidden are removed from the form entirely. - * - * @param FormInterface $form The form to modify - * @param BookingDtoInterface $bookingDto The booking data for context (create or edit) - * @param int $participantIndex The participant index - * @param array $formData Optional submitted form data for state calculation + * Adds base fields to the form with field states applied. */ - private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void + private function addBaseFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void { - // First, remove fields that should be excluded entirely - $this->removeExcludedFields($form, $bookingDto, $participantIndex, $formData); + // Get field states for base fields + $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex); - // Get all fields that have state conditions (excluding hidden fields) - $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData); + // Helper to get field state or empty array + $getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? []; - // Handle body dimension fields separately as they are nested in bodyDimensions form - $bodyDimensionFields = ['height', 'weight', 'shoeSize']; - $bodyDimensionStates = []; + $form + ->add('firstName', TextType::class, $this->mergeFieldState([ + 'label' => 'Vorname', + 'clean_xss' => true, + ], $getFieldState('firstName'))) + ->add('lastName', TextType::class, $this->mergeFieldState([ + 'label' => 'Nachname', + 'clean_xss' => true, + ], $getFieldState('lastName'))) + ->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([ + 'label' => 'Geburtsdatum', + 'html5' => true, + 'widget' => 'single_text', + 'input' => 'datetime_immutable', + ], $getFieldState('dateOfBirth'))) + ->add('gender', ChoiceType::class, $this->mergeFieldState([ + 'label' => 'Geschlecht', + 'required' => false, + 'placeholder' => 'keine Angabe', + 'choices' => [ + 'männlich' => 'M', + 'weiblich' => 'W', + 'divers' => 'D', + ], + ], $getFieldState('gender'))) + ->add('nationality', CountryType::class, $this->mergeFieldState([ + 'label' => 'Nationalität', + 'property' => 'nationality', + 'preferred_choices' => ['D', 'A', 'CH'], + ], $getFieldState('nationality'))) + ->add('email', EmailType::class, $this->mergeFieldState([ + 'label' => 'E-Mail', + 'required' => false, + 'clean_xss' => true, + ], $getFieldState('email'))) + ->add('mobile', TextType::class, $this->mergeFieldState([ + 'label' => 'Telefon (mobil)', + 'required' => false, + 'clean_xss' => true, + ], $getFieldState('mobile'))); - foreach ($allFieldStates as $fieldName => $fieldState) { - if (in_array($fieldName, $bodyDimensionFields, true)) { - // Collect body dimension field states for later processing - $bodyDimensionStates[$fieldName] = $fieldState; - continue; - } - - if ($form->has($fieldName)) { - $field = $form->get($fieldName); - $currentOptions = $field->getConfig()->getOptions(); - - // Merge state into current options - $updatedOptions = $this->mergeFieldState($currentOptions, $fieldState); - - // Remove and re-add the field with updated options - $fieldType = $field->getConfig()->getType()->getInnerType(); - $form->remove($fieldName); - $form->add($fieldName, $fieldType::class, $updatedOptions); + // Add body dimensions with state handling + $bodyDimensionStates = ['height' => $getFieldState('height'), 'weight' => $getFieldState('weight'), 'shoeSize' => $getFieldState('shoeSize')]; + $bodyDimensionOptions = []; + foreach ($bodyDimensionStates as $fieldName => $fieldState) { + if (isset($fieldState['required']) && true === $fieldState['required']) { + $bodyDimensionOptions[$fieldName.'_required'] = true; } } - - // Apply body dimension field states to the nested bodyDimensions form - if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) { - $this->applyBodyDimensionStates($form, $bodyDimensionStates); - } + $form->add('bodyDimensions', BodyDimensionsType::class, $bodyDimensionOptions); } /** @@ -209,30 +188,36 @@ class BookingCreateParticipantType extends AbstractType } /** - * Applies field states to body dimension fields in the nested bodyDimensions form. - * - * This method handles the special case of body dimension fields which are embedded - * in a nested form type. It converts field state requirements into options that - * can be passed to the BodyDimensionsType. - * - * @param FormInterface $form The parent form containing bodyDimensions - * @param array> $bodyDimensionStates Field states for body dimension fields + * Rebuilds all fields with updated states based on submitted data. */ - private function applyBodyDimensionStates(FormInterface $form, array $bodyDimensionStates): void + private function rebuildFieldsWithStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $submittedData): void { - $bodyDimensionsField = $form->get('bodyDimensions'); - $currentOptions = $bodyDimensionsField->getConfig()->getOptions(); + // First, remove fields that should be excluded entirely + $this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData); - // Convert field states to BodyDimensionsType options - foreach ($bodyDimensionStates as $fieldName => $fieldState) { - if (isset($fieldState['required']) && true === $fieldState['required']) { - $currentOptions[$fieldName.'_required'] = true; + // Clear the form and rebuild from scratch with updated states + + // Rebuild base fields with updated states + $baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile']; + foreach ($baseFields as $fieldName) { + if ($form->has($fieldName)) { + $form->remove($fieldName); } } - // Remove and re-add the bodyDimensions field with updated options - $form->remove('bodyDimensions'); - $form->add('bodyDimensions', BodyDimensionsType::class, $currentOptions); + // Re-add base fields with updated states + $this->addBaseFields($form, $bookingDto, $participantIndex); + + // Rebuild dynamic fields + $dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass']; + foreach ($dynamicFields as $fieldName) { + if ($form->has($fieldName)) { + $form->remove($fieldName); + } + } + + // Re-add dynamic fields + $this->addDynamicFields($form, $bookingDto, $participantIndex); } /** @@ -261,7 +246,7 @@ class BookingCreateParticipantType extends AbstractType $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex); // Skip choice fields without any choices - if ($fieldType === ChoiceType::class && false === $this->hasValidFieldOptions($fieldOptions)) { + if (ChoiceType::class === $fieldType && false === $this->hasValidFieldOptions($fieldOptions)) { continue; } diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index cc20e71..6a74feb 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -14,6 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateStep2Type extends AbstractType { + public function __construct( private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry, ) { diff --git a/src/Form/BookingEditParticipantType.php b/src/Form/BookingEditParticipantType.php index cd8f8c9..dca5a5a 100644 --- a/src/Form/BookingEditParticipantType.php +++ b/src/Form/BookingEditParticipantType.php @@ -20,9 +20,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class BookingEditParticipantType extends AbstractType { - public function __construct( - private readonly EditFieldStateProvider $fieldStateProvider, - ) { + public function __construct(private readonly EditFieldStateProvider $fieldStateProvider) + { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -299,14 +298,24 @@ class BookingEditParticipantType extends AbstractType $personalDataFields = [ 'firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile', ]; + // Re-apply field states by rebuilding fields with updated state + // Note: We need to store the field types and rebuild them since we cannot modify existing field configs + $fieldDefinitions = [ + 'firstName' => [TextType::class, ['label' => 'Vorname', 'clean_xss' => true]], + 'lastName' => [TextType::class, ['label' => 'Nachname', 'clean_xss' => true]], + 'dateOfBirth' => [BirthdayType::class, ['label' => 'Geburtsdatum', 'html5' => true, 'widget' => 'single_text', 'input' => 'datetime_immutable']], + 'gender' => [ChoiceType::class, ['label' => 'Geschlecht', 'required' => false, 'placeholder' => 'keine Angabe', 'choices' => ['männlich' => 'M', 'weiblich' => 'W', 'divers' => 'D']]], + 'nationality' => [CountryType::class, ['label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH']]], + 'email' => [EmailType::class, ['label' => 'E-Mail', 'required' => false, 'clean_xss' => true]], + 'mobile' => [TextType::class, ['label' => 'Telefon (mobil)', 'required' => false, 'clean_xss' => true]], + ]; + foreach ($personalDataFields as $field) { - if ($form->has($field)) { - $fieldConfig = $form->get($field)->getConfig(); - $currentOptions = $fieldConfig->getOptions(); - $updatedOptions = $this->mergeFieldState($currentOptions, $getState($field)); - $fieldType = $fieldConfig->getType()->getInnerType(); + if ($form->has($field) && isset($fieldDefinitions[$field])) { + [$fieldType, $baseOptions] = $fieldDefinitions[$field]; + $updatedOptions = $this->mergeFieldState($baseOptions, $getState($field)); $form->remove($field); - $form->add($field, $fieldType::class, $updatedOptions); + $form->add($field, $fieldType, $updatedOptions); } } diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php index 77b34f1..1de971e 100644 --- a/src/Form/RoomSelectType.php +++ b/src/Form/RoomSelectType.php @@ -17,12 +17,19 @@ class RoomSelectType extends AbstractType { $builder ->add('roomId', HiddenType::class) - ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + ->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) { $data = $event->getData(); $form = $event->getForm(); + // Build label with pricing + $label = 'Anzahl ' . $data->roomLabel; + if (null !== $options['room_price']) { + $formattedPrice = number_format((float) $options['room_price'], 2, ',', '.'); + $label .= sprintf(' (€%s pro Nacht)', $formattedPrice); + } + $form->add('quantity', ChoiceType::class, [ - 'label' => 'Anzahl '.$data->roomLabel, + 'label' => $label, 'required' => true, 'choices' => ['-' => 0] + array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)), ]); @@ -33,6 +40,7 @@ class RoomSelectType extends AbstractType { $resolver->setDefaults([ 'data_class' => RoomSelectionDto::class, + 'room_price' => null, ]); } } diff --git a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php index fdec951..606f2d5 100644 --- a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php @@ -39,10 +39,11 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter * @param string $fieldName The name of the field to configure * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being configured + * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ - public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array + public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array { // Check if we have a provider for this field if (false === isset($this->fieldOptionProviders[$fieldName])) { @@ -50,7 +51,7 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter } // Execute the provider with current context to generate dynamic options - return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex); + return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex, $options); } /** diff --git a/src/Form/Service/Contract/FieldOptionsProviderInterface.php b/src/Form/Service/Contract/FieldOptionsProviderInterface.php index ff38203..d2a6b28 100644 --- a/src/Form/Service/Contract/FieldOptionsProviderInterface.php +++ b/src/Form/Service/Contract/FieldOptionsProviderInterface.php @@ -44,10 +44,11 @@ interface FieldOptionsProviderInterface * @param string $fieldName The name of the field to configure * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being configured + * @param array $options Additional options to customize field behavior * * @return array Symfony form field options, or empty array if field not supported */ - public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array; + public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array; /** * Checks whether a field has option provider support. diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index dccf737..9e49355 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -137,7 +137,11 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField foreach ($selectedServices as $selectedService) { if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) { - $validSelections[] = $selectedService; + // Convert the selected service ID/data back to the actual Service object + $serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices); + if (null !== $serviceObject) { + $validSelections[] = $serviceObject; + } } } diff --git a/src/Form/Service/ParticipantBoardFieldHandler.php b/src/Form/Service/ParticipantBoardFieldHandler.php index 54a51fa..13f8af0 100644 --- a/src/Form/Service/ParticipantBoardFieldHandler.php +++ b/src/Form/Service/ParticipantBoardFieldHandler.php @@ -79,7 +79,11 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler foreach ($selectedServices as $selectedService) { if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) { - $validSelections[] = $selectedService; + // Convert the selected service ID/data back to the actual Service object + $serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices); + if (null !== $serviceObject) { + $validSelections[] = $serviceObject; + } } } diff --git a/src/Form/Service/ParticipantCoursesFieldHandler.php b/src/Form/Service/ParticipantCoursesFieldHandler.php index 96812b1..8fa6b9f 100644 --- a/src/Form/Service/ParticipantCoursesFieldHandler.php +++ b/src/Form/Service/ParticipantCoursesFieldHandler.php @@ -126,7 +126,11 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler foreach ($selectedServices as $selectedService) { if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) { - $validSelections[] = $selectedService; + // Convert the selected service ID/data back to the actual Service object + $serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices); + if (null !== $serviceObject) { + $validSelections[] = $serviceObject; + } } } diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index 1d798a2..1aefe41 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -6,6 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Service; use App\Form\Model\BookingDtoInterface; +use App\Form\Model\ParticipantDto; use App\Form\Service\Contract\ParticipantFieldHandlerInterface; /** @@ -17,7 +18,7 @@ use App\Form\Service\Contract\ParticipantFieldHandlerInterface; */ class ParticipantFieldHandlerRegistry { - /** @var ParticipantFieldHandlerInterface[] Registered handlers indexed by field name */ + /** @var array Registered handlers indexed by field name */ private array $handlers = []; /** @var string[]|null Cached array of handler names sorted by dependency order */ @@ -274,11 +275,11 @@ class ParticipantFieldHandlerRegistry * been processed by checking against registered handlers. * * @param array $participantData The submitted participant data - * @param object $participant The cleaned participant DTO + * @param ParticipantDto $participant The cleaned participant DTO * * @return array Updated participant data with synchronized field values */ - private function syncParticipantData(array $participantData, object $participant): array + private function syncParticipantData(array $participantData, ParticipantDto $participant): array { // Sync fields for all registered handlers foreach ($this->handlers as $fieldName => $handler) { diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 8464774..1573245 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -69,7 +69,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider protected function registerFieldOptionProviders(): void { // Room assignment field provider (only available for create workflow) - $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zimmer', 'placeholder' => 'Bitte wählen', // Use factory to create context-aware choice loader that: @@ -86,7 +86,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Courses field provider - provides age-appropriate courses from travel data - $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, @@ -97,11 +97,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => 'label', + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection - $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, @@ -112,7 +112,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => 'label', + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; @@ -133,7 +133,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Board field provider - provides age-appropriate board options from travel data - $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, @@ -144,11 +144,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => 'label', + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Rentals field provider - provides age-appropriate rental options from travel data filtered by date range - $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, @@ -159,11 +159,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => 'label', + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range - $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, @@ -174,11 +174,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participantIndex ), 'choice_value' => 'id', - 'choice_label' => 'label', + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) - $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Wünsche oder Anmerkungen zum Zimmer', 'required' => false, 'clean_xss' => true, @@ -199,6 +199,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // ]; } + /** + * Formats service label with pricing information. + * + * @param Service|null $service The service to format + * + * @return string The formatted label + */ + private function formatServiceLabelWithPrice(?Service $service): string + { + if (null === $service) { + return ''; + } + + if (null === $service->price || 0.0 === $service->price) { + return $service->label; + } + + return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); + } + /** * Filters services based on participant's age constraints. * diff --git a/src/Form/Service/ParticipantRentalsFieldHandler.php b/src/Form/Service/ParticipantRentalsFieldHandler.php index 569f9f3..5da61eb 100644 --- a/src/Form/Service/ParticipantRentalsFieldHandler.php +++ b/src/Form/Service/ParticipantRentalsFieldHandler.php @@ -79,7 +79,11 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler foreach ($selectedServices as $selectedService) { if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) { - $validSelections[] = $selectedService; + // Convert the selected service ID/data back to the actual Service object + $serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices); + if (null !== $serviceObject) { + $validSelections[] = $serviceObject; + } } } diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php new file mode 100644 index 0000000..8dc1a94 --- /dev/null +++ b/src/Service/BookingPriceCalculatorService.php @@ -0,0 +1,290 @@ +calculateRoomPricing($bookingDto); + $servicePricing = $this->calculateServicePricing($bookingDto); + $grandTotal = $this->calculateGrandTotal($bookingDto); + + return [ + 'rooms' => $roomPricing, + 'services' => $servicePricing, + 'grandTotal' => $grandTotal, + ]; + } + + /** + * Calculates pricing for all selected rooms. + * + * @param BookingDtoInterface $bookingDto The booking data containing room selections + * + * @return array Array of room pricing data with labels, quantities, and totals + */ + public function calculateRoomPricing(BookingDtoInterface $bookingDto): array + { + $roomPricing = []; + + if (false === $bookingDto instanceof BookingCreateDto) { + return $roomPricing; + } + + $selectedRooms = $bookingDto->getSelectedRooms(); + if (true === empty($selectedRooms)) { + return $roomPricing; + } + + foreach ($selectedRooms as $roomSelection) { + $room = $this->getRoomById($bookingDto, $roomSelection->roomId); + if (null === $room || null === $room->price) { + continue; + } + + $totalPrice = $roomSelection->quantity * $room->price; + + $roomPricing[] = [ + 'roomId' => $room->id, + 'label' => $room->label, + 'quantity' => $roomSelection->quantity, + 'unitPrice' => $room->price, + 'totalPrice' => $totalPrice, + ]; + } + + return $roomPricing; + } + + /** + * Calculates pricing for all selected services across all participants, grouped by subtype. + * + * @param BookingDtoInterface $bookingDto The booking data containing participants and their service selections + * + * @return array Array of service groups with each group containing services of the same subtype + */ + public function calculateServicePricing(BookingDtoInterface $bookingDto): array + { + $participants = $bookingDto->getParticipants(); + + if (true === empty($participants)) { + return []; + } + + // Aggregate service selections across all participants + $serviceAggregation = []; + + foreach ($participants as $participant) { + $this->aggregateParticipantServices($participant, $serviceAggregation); + } + + // Group services by subtype and convert to pricing format + return $this->groupServicesBySubtype($serviceAggregation); + } + + /** + * Calculates the grand total for the entire booking. + * + * @param BookingDtoInterface $bookingDto The booking data to calculate total for + * + * @return float The grand total price + */ + public function calculateGrandTotal(BookingDtoInterface $bookingDto): float + { + $roomTotal = $this->calculateRoomTotal($bookingDto); + $serviceTotal = $this->calculateServiceTotal($bookingDto); + + return $roomTotal + $serviceTotal; + } + + /** + * Calculates total price for all rooms. + */ + public function calculateRoomTotal(BookingDtoInterface $bookingDto): float + { + $roomPricing = $this->calculateRoomPricing($bookingDto); + + return array_sum(array_column($roomPricing, 'totalPrice')); + } + + /** + * Calculates total price for all services. + */ + public function calculateServiceTotal(BookingDtoInterface $bookingDto): float + { + $servicePricing = $this->calculateServicePricing($bookingDto); + + return array_sum(array_column($servicePricing, 'groupTotal')); + } + + /** + * Formats a price value for display with proper German formatting. + * + * @param float $price The price to format + * + * @return string Formatted price string (e.g., "123,45") + */ + public function formatPrice(float $price): string + { + return number_format($price, 2, ',', '.'); + } + + /** + * Formats a price with Euro symbol for display. + * + * @param float $price The price to format + * + * @return string Formatted price string with Euro symbol (e.g., "€123,45") + */ + public function formatPriceWithSymbol(float $price): string + { + return '€'.$this->formatPrice($price); + } + + /** + * Groups services by their subtypes for display. + * + * @param array $serviceAggregation Aggregated service data + * + * @return array Grouped services by subtype + */ + private function groupServicesBySubtype(array $serviceAggregation): array + { + $groupedServices = []; + + foreach ($serviceAggregation as $serviceData) { + if ($serviceData['totalPrice'] <= 0) { + continue; + } + + $subType = $serviceData['subType'] ?? 'other'; + $groupName = $this->getGroupNameForSubtype($subType); + + if (false === isset($groupedServices[$groupName])) { + $groupedServices[$groupName] = [ + 'groupName' => $groupName, + 'services' => [], + 'groupTotal' => 0.0, + ]; + } + + $groupedServices[$groupName]['services'][] = $serviceData; + $groupedServices[$groupName]['groupTotal'] += $serviceData['totalPrice']; + } + + return array_values($groupedServices); + } + + /** + * Maps service subtypes to user-friendly group names. + */ + private function getGroupNameForSubtype(string $subType): string + { + $groupMapping = [ + Constants::TOKEN_COURSES => 'Kurse', + Constants::TOKEN_SKI_PASS => 'Skipässe', + Constants::TOKEN_ADDITIONAL => 'Zusatzleistungen', + Constants::TOKEN_BOARD => 'Verpflegung', + ]; + + // Handle rentals array + if (true === in_array($subType, Constants::TOKEN_RENTALS, true)) { + return 'Leihmaterial'; + } + + return $groupMapping[$subType] ?? 'Sonstige Leistungen'; + } + + /** + * Aggregates service selections from a single participant into the service aggregation array. + */ + private function aggregateParticipantServices(ParticipantDto $participant, array &$serviceAggregation): void + { + // Handle single service selections (skiPass) + if (null !== $participant->skiPass && null !== $participant->skiPass->price) { + $this->addToServiceAggregation($serviceAggregation, $participant->skiPass, 1); + } + + // Handle multiple service selections + $multipleServiceArrays = [ + 'courses' => $participant->courses, + 'additionalServices' => $participant->additionalServices, + 'board' => $participant->board, + 'rentals' => $participant->rentals, + ]; + + foreach ($multipleServiceArrays as $serviceArray) { + if (true === is_array($serviceArray)) { + foreach ($serviceArray as $service) { + if ($service instanceof Service && null !== $service->price) { + $this->addToServiceAggregation($serviceAggregation, $service, 1); + } + } + } + } + } + + /** + * Adds a service to the aggregation array, incrementing count and updating total price. + */ + private function addToServiceAggregation(array &$serviceAggregation, Service $service, int $quantity): void + { + $serviceKey = $service->id.'_'.$service->label; + + if (false === isset($serviceAggregation[$serviceKey])) { + $serviceAggregation[$serviceKey] = [ + 'serviceId' => $service->id, + 'label' => $service->label, + 'unitPrice' => $service->price, + 'participantCount' => 0, + 'totalPrice' => 0.0, + 'subType' => $service->subType, + ]; + } + + $serviceAggregation[$serviceKey]['participantCount'] += $quantity; + $serviceAggregation[$serviceKey]['totalPrice'] += $service->price * $quantity; + } + + /** + * Retrieves a room by ID from the booking's travel data. + */ + private function getRoomById(BookingCreateDto $bookingDto, ?int $roomId): ?Room + { + if (null === $roomId) { + return null; + } + + foreach ($bookingDto->travel->rooms as $room) { + if ($room->id === $roomId) { + return $room; + } + } + + return null; + } +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 282fb19..f51b8c9 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -16,6 +16,7 @@ class BookingService public function __construct( private readonly TravelDataService $travelDataService, + private readonly BookingPriceCalculatorService $priceCalculator, ) { } @@ -156,18 +157,20 @@ class BookingService } /** - * Returns a summary of selected rooms and the resulting participant count for a booking. + * Returns a summary of selected rooms, participant count, and pricing information for a booking. * - * @return array{selectedRooms: array, participantCount: int} + * @return array{selectedRooms: array, participantCount: int, pricing: array} */ public function getRoomSummaryAndParticipantCount(BookingCreateDto $bookingCreateDto): array { $selectedRooms = $bookingCreateDto->getSelectedRooms(); $participantCount = $this->getParticipantsCount($selectedRooms, $bookingCreateDto->travel); + $pricing = $this->priceCalculator->getPricingBreakdown($bookingCreateDto); return [ 'selectedRooms' => $selectedRooms, 'participantCount' => $participantCount, + 'pricing' => $pricing, ]; } diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index 3ab0ec0..c969b2d 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -1,32 +1,72 @@ -

Zusammenfassung

-

Reise: {{ bookingCreateDto.travel.label }}

-

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

-

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

-{% if groupedSelectedRooms.by_room is not empty %} -

Zimmer

-
    - {% for roomSelection in groupedSelectedRooms.by_room %} -
  • - {{ roomSelection.quantity }} x {{ roomSelection.roomLabel }} - {% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %} - {{ assignmentCounts[roomSelection.roomId] }} belegt - {% endif %} -
  • - {% endfor %} -
-{% endif %} -{% if groupedSelectedRooms.by_pax is not empty %} -

Betten

-
    - {% for roomSelection in groupedSelectedRooms.by_pax %} -
  • - {{ roomSelection.quantity }} x {{ roomSelection.roomLabel }} - {% if assignmentCounts is defined and assignmentCounts[roomSelection.roomId] is defined %} - {{ assignmentCounts[roomSelection.roomId] }} belegt - {% endif %} -
  • - {% endfor %} -
-{% endif %} -

Anzahl Teilnehmer

-

{{ participantCount }}

+
+

Buchungsübersicht

+ + {# Travel Information #} +
+

Reiseinformationen

+
+
Reise: {{ bookingCreateDto.travel.label }}
+
Zeitraum: {{ bookingCreateDto.travel.dateFrom|date('d.m.Y') }} - {{ bookingCreateDto.travel.dateTo|date('d.m.Y') }}
+
Hotel: {{ bookingCreateDto.travel.hotel.name }}
+
Teilnehmer: {{ participantCount }}
+
+
+ + {# Rooms Section #} + {% if pricingData.rooms is not empty %} +
+

Unterkunft

+
+ {% for roomPricing in pricingData.rooms %} +
+
+ {{ roomPricing.quantity }}x {{ roomPricing.label }} + {% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %} + {{ assignmentCounts[roomPricing.roomId] }} belegt + {% endif %} +
+ + €{{ roomPricing.totalPrice|number_format(2, ',', '.') }} + +
+ {% endfor %} +
+
+ {% endif %} + + {# Services Section #} + {% if pricingData.services is not empty %} +
+

Leistungen

+ {% for serviceGroup in pricingData.services %} +
+
{{ serviceGroup.groupName }}
+
+ {% for servicePricing in serviceGroup.services %} +
+ + {{ servicePricing.participantCount }}x {{ servicePricing.label }} + + + €{{ servicePricing.totalPrice|number_format(2, ',', '.') }} + +
+ {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} + + {# Total Section #} + {% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %} +
+
+ Gesamtpreis: + + €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + +
+
+ {% endif %} +
diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index f5e52bd..e487445 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -27,9 +27,9 @@
{{ form_row(participant.firstName) }} {{ form_row(participant.lastName) }} - {# hx-swap="none" tells HTMX not to do a normal swap, as OOB will handle it #} {{ form_row(participant.dateOfBirth, { 'attr': { + 'hx-trigger': 'change', 'hx-post': path('app_booking_create_step_2_refresh'), 'hx-swap': 'none' } @@ -47,9 +47,9 @@ {{ form_row(participant.bodyDimensions.weight) }}
- {# hx-swap="none" tells HTMX not to do a normal swap, as OOB will handle it #} {{ form_row(participant.assignedRoomId, { 'attr': { + 'hx-trigger': 'change', 'hx-post': path('app_booking_create_step_2_refresh'), 'hx-swap': 'none' } @@ -60,19 +60,49 @@
{% if participant.skiPass is defined %} - {{ form_row(participant.skiPass) }} + {{ form_row(participant.skiPass, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-swap': 'none' + } + }) }} {% endif %} {% if participant.courses is defined %} - {{ form_row(participant.courses) }} + {{ form_row(participant.courses, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-swap': 'none' + } + }) }} {% endif %} {% if participant.additionalServices is defined %} - {{ form_row(participant.additionalServices) }} + {{ form_row(participant.additionalServices, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-swap': 'none' + } + }) }} {% endif %} {% if participant.rentals is defined %} - {{ form_row(participant.rentals) }} + {{ form_row(participant.rentals, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-swap': 'none' + } + }) }} {% endif %} {% if participant.board is defined %} - {{ form_row(participant.board) }} + {{ form_row(participant.board, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path('app_booking_create_step_2_refresh'), + 'hx-swap': 'none' + } + }) }} {% endif %}
diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 57f11cf..674e9b5 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -111,7 +111,7 @@ {{ form_widget(form) }}
- {{ form.vars.label | raw }} + {{ form.vars.label | raw }} {{- form_errors(form) -}}
@@ -137,7 +137,15 @@ {%- block choice_widget_expanded -%} {%- for child in form %} - {{- form_row(child) -}} + {%- set child_attr = {} -%} + {%- if attr['hx-trigger'] is defined -%} + {%- set child_attr = child_attr|merge({ + 'hx-trigger': attr['hx-trigger'], + 'hx-post': attr['hx-post'], + 'hx-swap': attr['hx-swap'] + }) -%} + {%- endif -%} + {{- form_row(child, { 'attr': child_attr }) -}} {% endfor -%} {%- endblock choice_widget_expanded -%}