From c793550af5f084ebb20c780c107d956549d7a788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 15 Jan 2026 10:28:26 +0100 Subject: [PATCH] feat: improved logic to control field visibility addresses #869btdphv --- src/Form/BookingParticipantType.php | 37 +- .../ParticipantFieldOptionsProvider.php | 639 +++++++++++------- 2 files changed, 381 insertions(+), 295 deletions(-) diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 16244f8..412b582 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -363,9 +363,9 @@ class BookingParticipantType extends AbstractType // Get base field options $fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex); - // Skip choice fields without any choices - $isChoiceField = ChoiceType::class === $fieldType || RoomAssignmentType::class === $fieldType; - if ($isChoiceField && false === $this->hasValidFieldOptions($fieldOptions)) { + // Skip fields where provider returns empty options + // Convention: providers return [] when field has no valid data (no choices, no services, etc.) + if (true === empty($fieldOptions)) { continue; } @@ -386,37 +386,6 @@ class BookingParticipantType extends AbstractType } } - /** - * Checks if field options contain valid choices for rendering. - * - * This method validates that the field options contain either choices array, - * choice_loader, or other valid choice sources. Empty choice arrays or - * null choice loaders indicate the field should not be rendered. - * - * @param array $fieldOptions The field options to validate - * - * @return bool True if the field has valid options for rendering, false otherwise - */ - private function hasValidFieldOptions(array $fieldOptions): bool - { - // Check if choices array exists and is not empty - if (false === empty($fieldOptions['choices'])) { - return true; - } - - // Check if choice_loader exists and is not null - if (isset($fieldOptions['choice_loader'])) { - return true; - } - - // Check for other valid choice sources - if (isset($fieldOptions['choice_list'])) { - return true; - } - - return false; - } - /** * Merges field state modifications into existing field options. * diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 103d30d..f88a485 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -53,24 +53,39 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider * Each provider is a callable that receives the booking DTO and participant * index and returns appropriate Symfony form field options. * - * Adding new fields: - * To add support for a new dynamic field, simply add a new provider here: + * Empty Array Convention: + * Providers MUST return an empty array when the field has no valid data to display. + * This signals to the form builder that the field should be skipped entirely. + * Examples: no choices available, no underlying services exist, etc. * - * $this->fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [ - * 'label' => 'New Field Label', - * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), - * ]; + * Adding new fields: + * To add support for a new dynamic field, add a provider following this pattern: + * + * $this->fieldOptionProviders['newField'] = function (BookingDto $bookingDto, int $participantIndex): array { + * $choices = $this->getChoicesFor($bookingDto, $participantIndex); + * + * if (true === empty($choices)) { + * return []; + * } + * + * return [ + * 'label' => 'New Field Label', + * 'choices' => $choices, + * ]; + * }; * * Provider Pattern Benefits: * - Lazy evaluation (options only generated when needed) * - Context-aware configuration * - Easy to test individual field logic * - Supports complex interdependencies + * - Unified empty-check pattern for all field types */ protected function registerFieldOptionProviders(): void { // Room assignment field provider (available for both create and edit workflows) - $this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []) { + // Returns empty array when no room choices available + $this->fieldOptionProviders['assignedRoomId'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { $choices = []; if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { @@ -85,6 +100,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms); } + if (true === empty($choices)) { + return []; + } + $singleChoice = 1 === count($choices); return [ @@ -100,169 +119,202 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider }; // Courses field provider - provides age-appropriate courses from travel data - $this->fieldOptionProviders['courses'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Kurse', - 'multiple' => true, - 'expanded' => true, - 'required' => false, - 'choices' => $this->filterServicesByAgeConstraints( + // Returns empty array when no courses available + $this->fieldOptionProviders['courses'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Kurse', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + $attributes = []; - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection - $this->fieldOptionProviders['additionalServices'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Zusatzleistungen', - 'multiple' => true, - 'expanded' => true, - 'required' => false, - 'choices' => $this->filterServicesByAgeConstraints( + // Returns empty array when no additional services available + $this->fieldOptionProviders['additionalServices'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Make readonly and checked for mandatory services (pre-selection handled by service layer) - if (true === $service->mandatory) { - $attributes['checked'] = true; - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar'; - } + return [ + 'label' => 'Zusatzleistungen', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + $attributes = []; - // Make readonly if service is unavailable (only if not already mandatory) - if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + // Make readonly and checked for mandatory services (pre-selection handled by service layer) + if (true === $service->mandatory) { + $attributes['checked'] = true; + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'Diese Leistung ist nicht abwählbar'; + } - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (only if not already mandatory) + if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Board field provider - provides age-appropriate board options from travel data - $this->fieldOptionProviders['board'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Verpflegung', - 'multiple' => true, - 'expanded' => true, - 'required' => false, - 'choices' => $this->filterServicesByAgeConstraints( + // Returns empty array when no board options available + $this->fieldOptionProviders['board'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Verpflegung', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + $attributes = []; - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Veg (vegetarian/vegan) field provider - provides dietary preference options as radio buttons (mutually exclusive) - $this->fieldOptionProviders['veg'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Verpflegungswunsch', - 'multiple' => false, - 'expanded' => true, - 'required' => false, - 'choices' => $this->filterServicesByAgeConstraints( + // Returns empty array when no dietary options available + $this->fieldOptionProviders['veg'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_VEG, true), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Verpflegungswunsch', + 'multiple' => false, + 'expanded' => true, + 'required' => false, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Check age restriction first (takes precedence over availability) - $ageEvaluator = new ServiceAgeEvaluator(); - if ($ageEvaluator->canEvaluate($service) - && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + $attributes = []; + + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Check age restriction first (takes precedence over availability) + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service) + && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + + return $attributes; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } return $attributes; - } - - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'veg')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } - - return $attributes; - }, - ]; + }, + ]; + }; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration - $this->fieldOptionProviders['rentals'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Leihmaterial', - 'multiple' => true, - 'expanded' => true, - 'required' => false, - 'choices' => $this->filterServicesByAgeConstraints( + // Returns empty array when no rental options available + $this->fieldOptionProviders['rentals'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterServicesByAgeConstraints( $this->filterRentalsBySkiPassDuration( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true), $bookingDto, @@ -270,30 +322,42 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Leihmaterial', + 'multiple' => true, + 'expanded' => true, + 'required' => false, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + $attributes = []; - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Rental insurance field provider - provides rental insurance options when rental services are selected $this->fieldOptionProviders['rentalInsurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ @@ -319,49 +383,58 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range - $this->fieldOptionProviders['skiPass'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Skipass', - 'multiple' => false, - 'expanded' => true, - 'required' => true, - 'choices' => $this->filterSkiPassChoices( + // Returns empty array when no skipass options available + $this->fieldOptionProviders['skiPass'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterSkiPassChoices( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true), $bookingDto, $participantIndex - ), - 'choice_value' => 'id', - 'choice_label' => fn (?Service $service) => $service?->label, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Skipass', + 'multiple' => false, + 'expanded' => true, + 'required' => true, + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Service $service) => $service?->label, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Check age restriction first (takes precedence over availability) - $ageEvaluator = new ServiceAgeEvaluator(); - if ($ageEvaluator->canEvaluate($service) - && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + $attributes = []; + + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Check age restriction first (takes precedence over availability) + $ageEvaluator = new ServiceAgeEvaluator(); + if ($ageEvaluator->canEvaluate($service) + && false === $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = $this->getAgeRestrictionTooltip($service); + + return $attributes; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } return $attributes; - } - - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } - - return $attributes; - }, - ]; + }, + ]; + }; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) $this->fieldOptionProviders['remarksRoom'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ @@ -376,89 +449,124 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Transportation field providers - handles outbound/inbound transportation and pickup selection // Outbound Transportation - $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Hinfahrt', - 'choices' => $this->filterTransportationChoices( + // Returns empty array when no transportation options available + $this->fieldOptionProviders['transportationOutbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->filterTransportationChoices( $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), $bookingDto, $participantIndex - ), - 'choice_label' => fn (Service $service) => $service?->label, - 'choice_value' => 'id', - 'expanded' => true, - 'multiple' => false, - 'required' => true, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + ); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Hinfahrt', + 'choices' => $choices, + 'choice_label' => fn (Service $service) => $service?->label, + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + $attributes = []; - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationOutbound')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Inbound Transportation - $this->fieldOptionProviders['transportationInbound'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Rückfahrt', - 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), - 'choice_label' => fn (Service $service) => $service?->label, - 'choice_value' => 'id', - 'expanded' => true, - 'multiple' => false, - 'required' => true, - 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { - if (null === $service) { - return []; - } + // Returns empty array when no transportation options available + $this->fieldOptionProviders['transportationInbound'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL); - $attributes = []; + if (true === empty($choices)) { + return []; + } - // Add service description as data attribute for frontend use - if (null !== $service->description && '' !== trim($service->description)) { - $attributes['data-description'] = $service->description; - } + return [ + 'label' => 'Rückfahrt', + 'choices' => $choices, + 'choice_label' => fn (Service $service) => $service?->label, + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } - // Make readonly if service is unavailable (intelligently handles edit mode) - if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { - $attributes['readonly'] = true; - $attributes['data-tooltip'] = 'ausgebucht'; - } + $attributes = []; - return $attributes; - }, - ]; + // Add service description as data attribute for frontend use + if (null !== $service->description && '' !== trim($service->description)) { + $attributes['data-description'] = $service->description; + } + + // Make readonly if service is unavailable (intelligently handles edit mode) + if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'transportationInbound')) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, + ]; + }; // Pickup (conditional - only shown when either transportation direction is bus) // Uses outbound pickups list, applies to both directions - $this->fieldOptionProviders['pickup'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Zu- und Ausstieg', - 'choices' => $bookingDto->travel->pickupsOutbound, - 'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(), - 'choice_value' => 'id', - 'expanded' => true, // Radio buttons in table layout like other services - 'multiple' => false, - 'required' => true, - ]; + // Returns empty array when no pickup options available + $this->fieldOptionProviders['pickup'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $bookingDto->travel->pickupsOutbound; + + if (true === empty($choices)) { + return []; + } + + return [ + 'label' => 'Zu- und Ausstieg', + 'choices' => $choices, + 'choice_label' => fn (?Pickup $pickup) => $pickup?->getLabel(), + 'choice_value' => 'id', + 'expanded' => true, // Radio buttons in table layout like other services + 'multiple' => false, + 'required' => true, + ]; + }; // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type - $this->fieldOptionProviders['parking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING)), - 'required' => false, - ]; + // Returns empty array when no parking services exist to prevent field from rendering + $this->fieldOptionProviders['parking'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $parkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING); + if (true === empty($parkingServices)) { + return []; + } + + return [ + 'label' => $this->getParkingCheckboxLabel($parkingServices), + 'required' => false, + ]; + }; // Bulk insurance booking checkbox (applicant only - controls insurance assignment for all participants) // Only registered in create mode - insurance cannot be modified in edit mode due to API limitation @@ -475,16 +583,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Insurance field provider - provides age and eligibility filtered insurances for participants // Only registered in create mode - insurance cannot be modified in edit mode due to API limitation - $this->fieldOptionProviders['insurance'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [ - 'label' => 'Reiseversicherung', - 'multiple' => false, - 'expanded' => true, - 'required' => false, - 'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead - 'choices' => $this->getEligibleInsurances($bookingDto, $participantIndex), - 'choice_value' => 'id', - 'choice_label' => fn (?Insurance $insurance) => $insurance?->label, - ]; + // Returns empty array when no insurance options available + $this->fieldOptionProviders['insurance'] = function (BookingDto $bookingDto, int $participantIndex, array $options = []): array { + $choices = $this->getEligibleInsurances($bookingDto, $participantIndex); + + if (true === empty($choices)) { + return []; + } + + return [ + 'label' => 'Reiseversicherung', + 'multiple' => false, + 'expanded' => true, + 'required' => false, + 'placeholder' => false, // Disable default placeholder - synthetic "keine Versicherung gewünscht" option injected instead + 'choices' => $choices, + 'choice_value' => 'id', + 'choice_label' => fn (?Insurance $insurance) => $insurance?->label, + ]; + }; // Purchase voucher field provider - redemption code for vouchers that apply to complete booking // Collected from all participants and aggregated into single collection in booking payload