diff --git a/config/services.yaml b/config/services.yaml index 3937eb4..29460f0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -74,7 +74,9 @@ services: # All handlers now have no external dependencies and can be instantiated directly - 'App\Form\Service\ParticipantDateOfBirthFieldHandler' - 'App\Form\Service\ParticipantAssignedRoomFieldHandler' + - 'App\Form\Service\ParticipantRemarksRoomFieldHandler' - 'App\Form\Service\ParticipantAdditionalServicesFieldHandler' - 'App\Form\Service\ParticipantCoursesFieldHandler' - 'App\Form\Service\ParticipantBoardFieldHandler' - 'App\Form\Service\ParticipantRentalsFieldHandler' + - 'App\Form\Service\ParticipantSkiPassFieldHandler' diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index 81a656c..a9a9f5b 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -11,6 +11,7 @@ use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\BirthdayType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; @@ -131,6 +132,7 @@ class BookingCreateParticipantType extends AbstractType * * 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) @@ -139,7 +141,10 @@ class BookingCreateParticipantType extends AbstractType */ private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void { - // Get all fields that have state conditions + // First, remove fields that should be excluded entirely + $this->removeExcludedFields($form, $bookingDto, $participantIndex, $formData); + + // Get all fields that have state conditions (excluding hidden fields) $allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData); // Handle body dimension fields separately as they are nested in bodyDimensions form @@ -153,7 +158,7 @@ class BookingCreateParticipantType extends AbstractType continue; } - if (true === $form->has($fieldName)) { + if ($form->has($fieldName)) { $field = $form->get($fieldName); $currentOptions = $field->getConfig()->getOptions(); @@ -168,11 +173,41 @@ class BookingCreateParticipantType extends AbstractType } // Apply body dimension field states to the nested bodyDimensions form - if (false === empty($bodyDimensionStates) && true === $form->has('bodyDimensions')) { + if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) { $this->applyBodyDimensionStates($form, $bodyDimensionStates); } } + /** + * Removes fields that should be excluded from the form entirely. + * + * This method handles dynamic field exclusion during form submission when + * field states change based on submitted data. + * + * @param FormInterface $form The form to modify + * @param BookingDtoInterface $bookingDto The booking data for context + * @param int $participantIndex The participant index + * @param array $formData Submitted form data for state calculation + */ + private function removeExcludedFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void + { + $dynamicFields = [ + 'assignedRoomId', + 'remarksRoom', + 'courses', + 'additionalServices', + 'board', + 'rentals', + 'skiPass', + ]; + + foreach ($dynamicFields as $fieldName) { + if ($form->has($fieldName) && !$this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex, $formData)) { + $form->remove($fieldName); + } + } + } + /** * Applies field states to body dimension fields in the nested bodyDimensions form. * @@ -205,23 +240,38 @@ class BookingCreateParticipantType extends AbstractType */ private function addDynamicFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void { - $dynamicFields = ['assignedRoomId', 'courses', 'additionalServices', 'board', 'rentals']; // List of fields that need dynamic configuration + $dynamicFields = [ + 'assignedRoomId' => ChoiceType::class, + 'remarksRoom' => TextareaType::class, + 'courses' => ChoiceType::class, + 'additionalServices' => ChoiceType::class, + 'board' => ChoiceType::class, + 'rentals' => ChoiceType::class, + 'skiPass' => ChoiceType::class, + ]; - foreach ($dynamicFields as $fieldName) { + foreach ($dynamicFields as $fieldName => $fieldType) { if ($this->fieldOptionsProvider->hasFieldOptions($fieldName)) { + // Check if field should be included in the form at all + if (false === $this->fieldStateProvider->shouldIncludeField($fieldName, $bookingDto, $participantIndex)) { + continue; + } + // Get base field options $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex); - // Only add the field if there are actually choices/options available - if (true === $this->hasValidFieldOptions($fieldOptions)) { - // Apply dynamic field state if conditions exist - if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) { - $fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex); - $fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState); - } - - $form->add($fieldName, ChoiceType::class, $fieldOptions); + // Skip choice fields without any choices + if ($fieldType === ChoiceType::class && false === $this->hasValidFieldOptions($fieldOptions)) { + continue; } + + // Apply non-hidden field states (readonly, disabled, required) + if (true === $this->fieldStateProvider->hasStateConditions($fieldName)) { + $fieldState = $this->fieldStateProvider->getFieldState($fieldName, $bookingDto, $participantIndex); + $fieldOptions = $this->mergeFieldState($fieldOptions, $fieldState); + } + + $form->add($fieldName, $fieldType, $fieldOptions); } } } diff --git a/src/Form/Model/BookingEditDto.php b/src/Form/Model/BookingEditDto.php index 688b992..0a936e7 100644 --- a/src/Form/Model/BookingEditDto.php +++ b/src/Form/Model/BookingEditDto.php @@ -34,8 +34,12 @@ class BookingEditDto implements BookingDtoInterface $participantData->courses = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_COURSES); - $participantData->skiPass = $booking + + // Skipass is single selection - take first item from array or null + $skipasses = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_SKI_PASS); + $participantData->skiPass = !empty($skipasses) ? $skipasses[0] : null; + $participantData->additionalServices = $booking ->getAdditionalServicesForParticipantByGroup($index, Constants::TOKEN_ADDITIONAL); $participantData->board = $booking diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 0bc0f11..4c07d45 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -44,9 +44,11 @@ class ParticipantDto #[Assert\NotNull(message: 'Bitte ein Zimmer auswählen', groups: ['booking_create_step_2'])] public ?int $assignedRoomId = null; + public ?string $remarksRoom = null; + public array $courses = []; public array $additionalServices = []; - public array $skiPass = []; + public ?Service $skiPass = null; public array $board = []; public array $rentals = []; public ?Service $transportationServiceTo = null; diff --git a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php index 0aca2fa..fdec951 100644 --- a/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldOptionsProvider.php @@ -45,7 +45,7 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array { // Check if we have a provider for this field - if (!isset($this->fieldOptionProviders[$fieldName])) { + if (false === isset($this->fieldOptionProviders[$fieldName])) { return []; } diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index 2182f60..a1b11c1 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -28,6 +28,30 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface $this->registerFieldStateConditions(); } + /** + * Determines whether a field should be included in the form at all. + * + * Fields with 'hidden' state conditions should not be added to the form + * rather than being hidden with CSS. This method evaluates the hidden + * condition independently to allow early field exclusion. + * + * @param string $fieldName The name of the field to evaluate + * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if the field should be included in the form, false if it should be excluded + */ + public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool + { + if (false === isset($this->fieldStateConditions[$fieldName]['hidden'])) { + return true; // No hidden condition means field should be included + } + + $hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden']; + return !$hiddenCondition->evaluate($bookingDto, $participantIndex, $formData); + } + /** * Calculates the dynamic state for a specified field. * @@ -35,6 +59,9 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface * state modifications. State conditions are organized by state type (readonly, * disabled, etc.) and evaluated independently. * + * Note: This method no longer handles 'hidden' state as fields should be + * excluded from the form entirely rather than hidden with CSS. + * * @param string $fieldName The name of the field to evaluate * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) * @param int $participantIndex The index of the participant being evaluated @@ -44,7 +71,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface */ public function getFieldState(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): array { - if (!isset($this->fieldStateConditions[$fieldName])) { + if (false === isset($this->fieldStateConditions[$fieldName])) { return []; } @@ -63,14 +90,11 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface case 'required': $stateModifications['required'] = true; break; - case 'hidden': - $attributes['style'] = ($attributes['style'] ?? '').' display: none;'; - break; } } } - if (!empty($attributes)) { + if (false === empty($attributes)) { $stateModifications['attr'] = $attributes; } diff --git a/src/Form/Service/Condition/AgeRangeCondition.php b/src/Form/Service/Condition/AgeRangeCondition.php index d1d2fe1..7ba8a75 100644 --- a/src/Form/Service/Condition/AgeRangeCondition.php +++ b/src/Form/Service/Condition/AgeRangeCondition.php @@ -126,7 +126,7 @@ class AgeRangeCondition implements FieldConditionInterface * * Uses DateTimeImmutable to ensure immutable date calculations and * handles the calculation accurately accounting for leap years and - * exact birth date anniversaries. + * exact birthdate anniversaries. * * @param \DateTimeImmutable $dateOfBirth The participant's date of birth * @@ -136,6 +136,6 @@ class AgeRangeCondition implements FieldConditionInterface { $today = new \DateTimeImmutable(); - return (int) $dateOfBirth->diff($today)->y; + return $dateOfBirth->diff($today)->y; } } diff --git a/src/Form/Service/Condition/CompositeCondition.php b/src/Form/Service/Condition/CompositeCondition.php index 6ae5f42..f667546 100644 --- a/src/Form/Service/Condition/CompositeCondition.php +++ b/src/Form/Service/Condition/CompositeCondition.php @@ -211,7 +211,7 @@ class CompositeCondition implements FieldConditionInterface { $validOperators = [self::OPERATOR_AND, self::OPERATOR_OR, self::OPERATOR_NOT]; - if (!in_array($operator, $validOperators, true)) { + if (false === in_array($operator, $validOperators, true)) { throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators))); } } diff --git a/src/Form/Service/Condition/RoomSelectionCondition.php b/src/Form/Service/Condition/RoomSelectionCondition.php new file mode 100644 index 0000000..f4cc9bd --- /dev/null +++ b/src/Form/Service/Condition/RoomSelectionCondition.php @@ -0,0 +1,111 @@ + $requiredRoomCodes Room codes that should trigger this condition + */ + public function __construct(array $requiredRoomCodes) + { + $this->requiredRoomCodes = array_map('strtolower', $requiredRoomCodes); + } + + /** + * Evaluates whether the participant has selected a room with one of the required codes. + * + * Checks both submitted form data and participant DTO data to determine + * if the selected room matches any of the configured room codes. + * + * @param BookingDtoInterface $bookingDto The current booking data (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if room with matching code is selected, false otherwise + */ + public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + { + // First check submitted form data for room selection + if (isset($formData['participants'][$participantIndex]['assignedRoomId'])) { + $selectedRoomId = $formData['participants'][$participantIndex]['assignedRoomId']; + if (true === is_numeric($selectedRoomId)) { + $room = $this->findRoomById((int) $selectedRoomId, $bookingDto); + if (null !== $room && in_array(strtolower($room->code ?? ''), $this->requiredRoomCodes, true)) { + return true; + } + } + } + + // Then check participant DTO data for existing room selection + $participant = $bookingDto->getParticipant($participantIndex); + if (null !== $participant && null !== $participant->assignedRoomId) { + $room = $this->findRoomById($participant->assignedRoomId, $bookingDto); + if (null !== $room && in_array(strtolower($room->code ?? ''), $this->requiredRoomCodes, true)) { + return true; + } + } + + return false; + } + + /** + * Returns field names that trigger re-evaluation of this condition. + * + * This condition depends on the assignedRoomId field, so any changes to room + * selection should trigger re-evaluation of dependent fields. + * + * @return string[] Array containing the field names that affect this condition + */ + public function getDependentFields(): array + { + return ['assignedRoomId']; + } + + /** + * Returns a human-readable description of this condition. + * + * @return string A brief description of the condition logic + */ + public function getDescription(): string + { + $codes = implode(', ', $this->requiredRoomCodes); + return "Field visible when room with code(s) [{$codes}] is selected"; + } + + /** + * Finds a room by ID in the available travel rooms. + * + * @param int $roomId The room ID to find + * @param BookingDtoInterface $bookingDto The booking DTO containing travel data + * + * @return Room|null The found room or null if not found + */ + private function findRoomById(int $roomId, BookingDtoInterface $bookingDto): ?Room + { + foreach ($bookingDto->travel->rooms as $room) { + if ($room->id === $roomId) { + return $room; + } + } + + return null; + } +} diff --git a/src/Form/Service/Contract/FieldStateProviderInterface.php b/src/Form/Service/Contract/FieldStateProviderInterface.php index b476e37..10f5b4e 100644 --- a/src/Form/Service/Contract/FieldStateProviderInterface.php +++ b/src/Form/Service/Contract/FieldStateProviderInterface.php @@ -30,6 +30,22 @@ use App\Form\Model\BookingDtoInterface; */ interface FieldStateProviderInterface { + /** + * Determines whether a field should be included in the form at all. + * + * Fields with 'hidden' state conditions should not be added to the form + * rather than being hidden with CSS. This method evaluates the hidden + * condition independently to allow early field exclusion during form building. + * + * @param string $fieldName The name of the field to evaluate + * @param BookingDtoInterface $bookingDto The current booking data for context (create or edit) + * @param int $participantIndex The index of the participant being evaluated + * @param array $formData Current form data for condition evaluation + * + * @return bool True if the field should be included in the form, false if it should be excluded + */ + public function shouldIncludeField(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): bool; + /** * Calculates the dynamic state for a specified field. * @@ -38,11 +54,14 @@ interface FieldStateProviderInterface * The returned array contains Symfony form field attributes that control * field behavior and appearance. * + * Note: This method no longer handles 'hidden' state as fields should be + * excluded from the form entirely using shouldIncludeField() rather than + * hidden with CSS. + * * State attributes may include: * - 'attr' => ['readonly' => true] - Make field readonly * - 'disabled' => true - Disable field interaction * - 'required' => false - Override field requirement - * - 'attr' => ['style' => 'display: none'] - Hide field * - 'attr' => ['class' => 'conditional-field'] - Add CSS classes * * @param string $fieldName The name of the field to evaluate diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 035abd1..7f8795d 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -8,6 +8,7 @@ use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; use App\Form\Service\Condition\RentalSelectionCondition; +use App\Form\Service\Condition\RoomSelectionCondition; /** * Field state provider for the booking create workflow. @@ -83,6 +84,14 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition), ]; + // Room-specific field conditions + $mbzRoomCondition = new RoomSelectionCondition(['mbz']); + + // Show remarks room field only when room with code 'mbz' is selected + $this->fieldStateConditions['remarksRoom'] = [ + 'hidden' => CompositeCondition::not($mbzRoomCondition), + ]; + // Example field state conditions would be registered here // For demonstration purposes, here are some example patterns: diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index 4c5a6f7..dccf737 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -50,6 +50,24 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField return ['dateOfBirth']; } + /** + * Determines if this handler should process the field based on submitted data. + * + * For service selection fields like additional services, we always need to process + * to handle cases where all selections are cleared (field not present in data). + * This ensures the participant DTO is updated with an empty array when no + * services are selected. + * + * @param array $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + /** * Processes the additionalServices field for a specific participant. * @@ -154,7 +172,7 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField // Check if service has age constraints $ageEvaluator = new ServiceAgeEvaluator(); - if (!$ageEvaluator->canEvaluate($service)) { + if (false === $ageEvaluator->canEvaluate($service)) { return true; // No age restrictions, service is valid } diff --git a/src/Form/Service/ParticipantBoardFieldHandler.php b/src/Form/Service/ParticipantBoardFieldHandler.php index 54383ad..54a51fa 100644 --- a/src/Form/Service/ParticipantBoardFieldHandler.php +++ b/src/Form/Service/ParticipantBoardFieldHandler.php @@ -31,6 +31,24 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler return ['dateOfBirth']; } + /** + * Determines if this handler should process the field based on submitted data. + * + * For service selection fields like board options, we always need to process + * to handle cases where all selections are cleared (field not present in data). + * This ensures the participant DTO is updated with an empty array when no + * services are selected. + * + * @param array $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); @@ -81,7 +99,7 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler } $ageEvaluator = new ServiceAgeEvaluator(); - if (!$ageEvaluator->canEvaluate($service)) { + if (false === $ageEvaluator->canEvaluate($service)) { return true; } diff --git a/src/Form/Service/ParticipantCoursesFieldHandler.php b/src/Form/Service/ParticipantCoursesFieldHandler.php index 1581725..96812b1 100644 --- a/src/Form/Service/ParticipantCoursesFieldHandler.php +++ b/src/Form/Service/ParticipantCoursesFieldHandler.php @@ -50,6 +50,24 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler return ['dateOfBirth']; } + /** + * Determines if this handler should process the field based on submitted data. + * + * For service selection fields like courses, we always need to process + * to handle cases where all selections are cleared (field not present in data). + * This ensures the participant DTO is updated with an empty array when no + * services are selected. + * + * @param array $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + /** * Processes the courses field for a specific participant. * @@ -140,7 +158,7 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler // Check if service has age constraints $ageEvaluator = new ServiceAgeEvaluator(); - if (!$ageEvaluator->canEvaluate($service)) { + if (false === $ageEvaluator->canEvaluate($service)) { return true; // No age restrictions, service is valid } diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index 96f725c..1d798a2 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Form\Service; +use App\BusProNet\Model\Service; use App\Form\Model\BookingDtoInterface; use App\Form\Service\Contract\ParticipantFieldHandlerInterface; @@ -98,7 +99,7 @@ class ParticipantFieldHandlerRegistry public function processFields(array $submittedData, BookingDtoInterface $bookingDto): void { // Early return if no participant data exists in submission - if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) { + if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { return; } @@ -108,7 +109,7 @@ class ParticipantFieldHandlerRegistry // Process each participant's data foreach ($submittedData['participants'] as $participantIndex => $participantData) { // Skip invalid participant data - if (!is_array($participantData)) { + if (false === is_array($participantData)) { continue; } @@ -180,7 +181,7 @@ class ParticipantFieldHandlerRegistry foreach ($this->handlers as $handlerName => $handler) { foreach ($handler->getDependencies() as $dependency) { // Validate that the dependency exists - if (!isset($this->handlers[$dependency])) { + if (false === isset($this->handlers[$dependency])) { throw new \InvalidArgumentException(sprintf('Handler "%s" depends on unknown handler "%s"', $handlerName, $dependency)); } @@ -202,7 +203,7 @@ class ParticipantFieldHandlerRegistry } // Process handlers in dependency order - while (!empty($queue)) { + while (false === empty($queue)) { $current = array_shift($queue); $result[] = $current; @@ -243,13 +244,13 @@ class ParticipantFieldHandlerRegistry private function syncSubmittedDataWithDto(array $submittedData, BookingDtoInterface $bookingDto): array { // Ensure participants array exists in submitted data - if (!isset($submittedData['participants']) || !is_array($submittedData['participants'])) { + if (false === isset($submittedData['participants']) || false === is_array($submittedData['participants'])) { return $submittedData; } // Sync each participant's data with the cleaned DTO foreach ($submittedData['participants'] as $index => $participantData) { - if (!is_array($participantData)) { + if (false === is_array($participantData)) { continue; } @@ -333,7 +334,7 @@ class ParticipantFieldHandlerRegistry private function convertSingleValueToSubmittedFormat(mixed $value): mixed { // Handle Service objects -> convert to ID - if ($value instanceof \App\BusProNet\Model\Service) { + if ($value instanceof Service) { return $value->id; } diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 58ff584..8464774 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -96,6 +96,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $bookingDto, $participantIndex ), + 'choice_value' => 'id', 'choice_label' => 'label', ]; @@ -142,6 +143,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $bookingDto, $participantIndex ), + 'choice_value' => 'id', 'choice_label' => 'label', ]; @@ -156,9 +158,35 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $bookingDto, $participantIndex ), + 'choice_value' => 'id', 'choice_label' => 'label', ]; + // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range + $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Skipass', + 'multiple' => false, + 'expanded' => true, + 'required' => true, + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true), + $bookingDto, + $participantIndex + ), + 'choice_value' => 'id', + 'choice_label' => 'label', + ]; + + // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) + $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [ + 'label' => 'Wünsche oder Anmerkungen zum Zimmer', + 'required' => false, + 'clean_xss' => true, + 'attr' => [ + 'rows' => 2, + ], + ]; + // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ @@ -190,7 +218,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $participant = $bookingDto->getParticipant($participantIndex); - // If no birth date provided, return empty array (handled by field visibility conditions) + // If no birthdate provided, return empty array (handled by field visibility conditions) if (null === $participant || null === $participant->dateOfBirth) { return []; } diff --git a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php new file mode 100644 index 0000000..d2fa113 --- /dev/null +++ b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php @@ -0,0 +1,61 @@ + $submittedData The submitted participant form data + * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param int $participantIndex The index of the participant being processed + */ + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + // Safely get the participant object, returning early if not found + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Extract the room remarks from form data + $remarksRoom = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Normalize empty string to null and update participant + $participant->remarksRoom = $this->normalizeEmptyValue($remarksRoom); + } +} \ No newline at end of file diff --git a/src/Form/Service/ParticipantRentalsFieldHandler.php b/src/Form/Service/ParticipantRentalsFieldHandler.php index cb89730..569f9f3 100644 --- a/src/Form/Service/ParticipantRentalsFieldHandler.php +++ b/src/Form/Service/ParticipantRentalsFieldHandler.php @@ -31,6 +31,24 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler return ['dateOfBirth']; } + /** + * Determines if this handler should process the field based on submitted data. + * + * For service selection fields like rentals, we always need to process + * to handle cases where all selections are cleared (field not present in data). + * This ensures the participant DTO is updated with an empty array when no + * services are selected. + * + * @param array $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void { $participant = $this->getParticipant($bookingDto, $participantIndex); diff --git a/src/Form/Service/ParticipantSkiPassFieldHandler.php b/src/Form/Service/ParticipantSkiPassFieldHandler.php new file mode 100644 index 0000000..05efa0e --- /dev/null +++ b/src/Form/Service/ParticipantSkiPassFieldHandler.php @@ -0,0 +1,206 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for service selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle deselection cases + } + + /** + * Processes the skiPass field for a specific participant. + * + * This method extracts the skipass selection from submitted form data, + * validates the selection against the participant's age constraints and + * travel date constraints, and updates the participant DTO with the valid + * selection. If the skipass is no longer appropriate for the participant's + * age or exceeds the travel date range, it is automatically cleared. + * + * @param array $submittedData The submitted participant form data + * @param BookingDtoInterface $bookingDto The booking DTO to update (create or edit) + * @param int $participantIndex The index of the participant being processed + */ + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + // Safely get the participant object, returning early if not found + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Extract current skipass selection from submitted data + $selectedSkiPass = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available skipasses from travel data (with date filtering) + $availableSkipasses = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true); + + // For single selection, validate the selected skipass and convert ID to Service object + $validSelection = null; + if (null !== $selectedSkiPass) { + if ($this->isServiceValidForParticipant($selectedSkiPass, $availableSkipasses, $bookingDto, $participantIndex)) { + // Convert the submitted ID back to the Service object + $validSelection = $this->findServiceInAvailableServices($selectedSkiPass, $availableSkipasses); + } + } + + // Update participant with validated selection + $participant->skiPass = $validSelection; + } + + + /** + * Validates if a selected skipass is still valid for the participant. + * + * This method checks both age constraints (via birth year ranges) and + * date constraints (skipass dates must be within travel dates). + * + * @param mixed $selectedService The selected skipass to validate + * @param array $availableServices Array of available skipasses + * @param BookingDtoInterface $bookingDto The booking DTO for context + * @param int $participantIndex The participant index for age evaluation + * + * @return bool True if the skipass is valid for the participant, false otherwise + */ + private function isServiceValidForParticipant( + mixed $selectedService, + array $availableServices, + BookingDtoInterface $bookingDto, + int $participantIndex, + ): bool { + // Find the service in available services + $service = $this->findServiceInAvailableServices($selectedService, $availableServices); + + if (null === $service) { + return false; // Service not found in available services + } + + // Check if service has age constraints + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service)) { + // Validate service against participant's age + if (false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + return false; // Age constraints not met + } + } + + // Date constraints are already handled by the travel->getAdditionalServicesBySubTypes + // method with date filtering enabled (true, true parameters), so if the service + // is in the available services list, it already passed date validation. + + return true; // Service passed both age and date validation + } + + /** + * Finds a selected skipass in the list of available skipasses. + * + * @param mixed $selectedService The selected skipass to find + * @param array $availableServices Array of available Service objects + * + * @return Service|null The found service or null if not found + */ + private function findServiceInAvailableServices(mixed $selectedService, array $availableServices): ?Service + { + foreach ($availableServices as $availableService) { + if ($this->servicesMatch($selectedService, $availableService)) { + return $availableService; + } + } + + return null; + } + + /** + * Determines if a selected skipass matches an available skipass. + * + * @param mixed $selectedService The selected skipass from form data + * @param Service $availableService The available skipass to compare against + * + * @return bool True if the skipasses match, false otherwise + */ + private function servicesMatch(mixed $selectedService, Service $availableService): bool + { + // Direct object comparison + if ($selectedService === $availableService) { + return true; + } + + // ID comparison for Service objects + if ($selectedService instanceof Service) { + return $selectedService->id === $availableService->id; + } + + // ID comparison for numeric values + if (is_numeric($selectedService)) { + return (int) $selectedService === $availableService->id; + } + + // String ID comparison + if (is_string($selectedService)) { + return $selectedService === (string) $availableService->id; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Form/Service/ServiceAgeEvaluator.php b/src/Form/Service/ServiceAgeEvaluator.php index 12043dc..8b16f19 100644 --- a/src/Form/Service/ServiceAgeEvaluator.php +++ b/src/Form/Service/ServiceAgeEvaluator.php @@ -118,7 +118,7 @@ class ServiceAgeEvaluator { $today = new \DateTimeImmutable(); - return (int) $dateOfBirth->diff($today)->y; + return $dateOfBirth->diff($today)->y; } /** diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index 3b15a49..f5e52bd 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -46,7 +46,7 @@ {{ form_row(participant.bodyDimensions.shoeSize) }} {{ 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': { @@ -54,8 +54,14 @@ 'hx-swap': 'none' } }) }} + {% if participant.remarksRoom is defined %} + {{ form_row(participant.remarksRoom) }} + {% endif %}
+ {% if participant.skiPass is defined %} + {{ form_row(participant.skiPass) }} + {% endif %} {% if participant.courses is defined %} {{ form_row(participant.courses) }} {% endif %}