diff --git a/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md b/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md index e98563b..e913259 100644 --- a/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md +++ b/docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md @@ -393,8 +393,8 @@ class ParticipantParkingFieldHandler extends AbstractParticipantFieldHandler private function isParkingApplicable($participant): bool { - $outboundIsPkw = $participant->transportationOutbound?->subType === 'PKW'; - $inboundIsPkw = $participant->transportationInbound?->subType === 'PKW'; + $outboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType; + $inboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationInbound?->subType; return $outboundIsPkw || $inboundIsPkw; } @@ -487,8 +487,8 @@ private function formatTransportationServiceLabel(Service $service): string // Add transportation type indicator $typeIndicator = match($service->subType) { - 'BUS' => '🚌', - 'PKW' => '🚗', + DirectionMapper::SUBTYPE_BUS_API => '🚌', + DirectionMapper::SUBTYPE_CAR_API => '🚗', default => '' }; @@ -514,9 +514,9 @@ private function formatTransportationServiceLabel(Service $service): string ### Phase 3: Conditional Field States & UX 🎨 -#### 3.1 Transportation Type Condition +#### 3.1 Service Sub-Type Condition -**File:** `src/Form/Service/Condition/TransportationTypeCondition.php` +**File:** `src/Form/Service/Condition/ServiceSubTypeCondition.php` ```php fieldStateConditions['pickupOutbound'] = [ - 'hidden' => CompositeCondition::not( - new TransportationTypeCondition('outbound', 'BUS') - ), + 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API), ]; - // Show inbound pickup only when inbound transportation is BUS + // Hide inbound pickup when transportation is not BUS $this->fieldStateConditions['pickupInbound'] = [ - 'hidden' => CompositeCondition::not( - new TransportationTypeCondition('inbound', 'BUS') - ), + 'hidden' => ServiceSubTypeCondition::notEquals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API), ]; - // Show parking only when at least one direction is PKW + // Hide parking when outbound transportation is not PKW (car) + // Parking is offered at holiday destination for those arriving by car $this->fieldStateConditions['parking'] = [ - 'hidden' => CompositeCondition::not( - CompositeCondition::or( - new TransportationTypeCondition('outbound', 'PKW'), - new TransportationTypeCondition('inbound', 'PKW') - ) - ), + 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API), ]; } ``` @@ -667,15 +665,40 @@ $dynamicFields = [ #### 4.3 Constants Update -**Update:** `src/BusProNet/Constants.php` +**Update:** `src/BusProNet/Utility/DirectionMapper.php` ```php -// Add parking token if not already defined -public const TOKEN_PARKING = 'PAR'; +// Transportation service sub-types (API format - German abbreviations) +public const SUBTYPE_BUS_API = 'BUS'; +public const SUBTYPE_CAR_API = 'PKW'; -// Transportation subtypes +// Transportation service sub-types (internal format - English) public const SUBTYPE_BUS = 'BUS'; -public const SUBTYPE_PKW = 'PKW'; +public const SUBTYPE_CAR = 'CAR'; + +/** + * Maps API transportation sub-type to internal sub-type. + */ +public static function apiToInternal(string $apiSubType): string +{ + return match ($apiSubType) { + self::SUBTYPE_BUS_API => self::SUBTYPE_BUS, + self::SUBTYPE_CAR_API => self::SUBTYPE_CAR, + default => throw new \InvalidArgumentException("Unknown API sub-type: $apiSubType"), + }; +} + +/** + * Maps internal transportation sub-type to API sub-type. + */ +public static function internalToApi(string $internalSubType): string +{ + return match ($internalSubType) { + self::SUBTYPE_BUS => self::SUBTYPE_BUS_API, + self::SUBTYPE_CAR => self::SUBTYPE_CAR_API, + default => throw new \InvalidArgumentException("Unknown internal sub-type: $internalSubType"), + }; +} ``` ## UX Design & User Experience 🎯 @@ -818,27 +841,62 @@ public const SUBTYPE_PKW = 'PKW'; ### Sprint 1: Foundation (Week 1) - ✅ Create documentation -- ✅ Implement DirectionMapper utility +- ✅ Implement DirectionMapper utility (removed unused toEnglish method) - ✅ Update ParticipantDto properties - ✅ Update BookingEditDto mapping +- ✅ Create transportation field handlers (Outbound/Inbound) +- ✅ Create pickup field handlers with conditional logic +- ✅ Add parking service handler for self-organized transport +- ✅ Add TOKEN_PARKING constant +- ✅ Update ParticipantFieldOptionsProvider for transportation services +- ✅ Integrate transportation fields into BookingCreateParticipantType +- ✅ Create ServiceSubTypeCondition for field state management +- ✅ Update field state provider with transportation conditions +- ✅ Implement transportation type mapping for API/internal consistency -### Sprint 2: Core Implementation (Week 2) -- Create transportation field handlers -- Create pickup field handlers -- Add parking service handler -- Update field options provider +### Sprint 2: Advanced Features (Week 2) +- 🚧 Add pricing integration for transportation services +- 🚧 Update HTMX integration for real-time transportation updates -### Sprint 3: UX & Integration (Week 3) -- Implement conditional field states -- Update form integration -- Add transportation service configuration -- Implement HTMX real-time updates +### Sprint 3: Testing & Deployment (Week 3) +- Unit testing for all components +- Integration testing for form flow +- Manual testing scenarios +- Performance optimization -### Sprint 4: Testing & Polish (Week 4) -- Add pricing integration -- Comprehensive testing -- UX refinements -- Documentation updates +## Key Implementation Highlights 🌟 + +### Transportation Type Mapping System + +**Problem Solved:** BusProNet uses German abbreviations ('PKW') while internal code should use English terminology ('CAR') for consistency. + +**Solution:** Enhanced `DirectionMapper` utility with bidirectional mapping: +- **API Format:** `SUBTYPE_CAR_API = 'PKW'`, `SUBTYPE_BUS_API = 'BUS'` +- **Internal Format:** `SUBTYPE_CAR = 'CAR'`, `SUBTYPE_BUS = 'BUS'` +- **Mapping Methods:** `apiToInternal()`, `internalToApi()`, validation helpers + +### Generic Service Sub-Type Condition + +**Achievement:** Created reusable `ServiceSubTypeCondition` instead of transportation-specific logic: +- Supports multiple operators: `equals`, `notEquals`, `in`, `notIn` +- Works with any service field, not just transportation +- Handles both API and internal sub-type values +- Provides static factory methods for common use cases + +### Conditional UX Logic + +**Smart Field Visibility:** +- **Pickup Fields:** Only visible when respective transportation is BUS +- **Parking Field:** Only visible when outbound transportation is CAR (PKW) +- Uses API constants since Service objects contain API values +- Proper business logic: parking needed at destination for car arrivals + +### Backward Compatibility + +- Maintained all existing property names with deprecation notices +- API integration continues using BusProNet's expected format +- Internal code uses clean English naming +- Seamless migration path for existing functionality ## Success Criteria ✅ diff --git a/src/BusProNet/Constants.php b/src/BusProNet/Constants.php index c04ea17..04fbfb6 100644 --- a/src/BusProNet/Constants.php +++ b/src/BusProNet/Constants.php @@ -15,6 +15,7 @@ final class Constants public const TOKEN_ADDITIONAL = 'SON'; public const TOKEN_BOARD = 'VPF'; public const TOKEN_RENTALS = ['VER', 'VE2', 'VE3', 'VE4', 'VE5', 'VE6', 'VE7', 'VE8']; + public const TOKEN_PARKING = 'PAR'; public const STATUS_AVAILABLE = 'Frei'; public const STATUS_BLOCKED = 'Buchungsstop'; diff --git a/src/BusProNet/Utility/DirectionMapper.php b/src/BusProNet/Utility/DirectionMapper.php index fbf2790..3bba221 100644 --- a/src/BusProNet/Utility/DirectionMapper.php +++ b/src/BusProNet/Utility/DirectionMapper.php @@ -5,12 +5,16 @@ declare(strict_types=1); namespace App\BusProNet\Utility; /** - * Handles direction mapping between BusProNet's inconsistent direction codes. + * Handles direction and transportation type mapping for BusProNet inconsistencies. * * BusProNet uses different direction codes in different contexts: * - Travel data: 'HIN' (outbound), 'RUECK' (inbound) * - Booking data: 'H' (outbound), 'R' (inbound) * + * BusProNet also uses German abbreviations for transportation sub-types: + * - API format: 'PKW' (car), 'BUS' (bus) + * - Internal format: 'CAR' (car), 'BUS' (bus) + * * This utility provides consistent mapping between formats and enables * clean English naming for internal application use. */ @@ -24,6 +28,14 @@ final class DirectionMapper public const OUTBOUND_BOOKING = 'H'; public const INBOUND_BOOKING = 'R'; + // Transportation service sub-types (API format - German abbreviations) + public const SUBTYPE_BUS_API = 'BUS'; + public const SUBTYPE_CAR_API = 'PKW'; + + // Transportation service sub-types (internal format - English) + public const SUBTYPE_BUS = 'BUS'; + public const SUBTYPE_CAR = 'CAR'; + /** * Maps travel direction code to booking direction code. * @@ -115,4 +127,76 @@ final class DirectionMapper { return self::isOutbound($direction) || self::isInbound($direction); } + + /** + * Maps API transportation sub-type to internal sub-type. + * + * @param string $apiSubType The API transportation sub-type ('BUS' or 'PKW') + * + * @return string The internal transportation sub-type ('BUS' or 'CAR') + * + * @throws \InvalidArgumentException When sub-type is not recognized + */ + public static function apiToInternal(string $apiSubType): string + { + return match ($apiSubType) { + self::SUBTYPE_BUS_API => self::SUBTYPE_BUS, + self::SUBTYPE_CAR_API => self::SUBTYPE_CAR, + default => throw new \InvalidArgumentException("Unknown API sub-type: $apiSubType"), + }; + } + + /** + * Maps internal transportation sub-type to API sub-type. + * + * @param string $internalSubType The internal transportation sub-type ('BUS' or 'CAR') + * + * @return string The API transportation sub-type ('BUS' or 'PKW') + * + * @throws \InvalidArgumentException When sub-type is not recognized + */ + public static function internalToApi(string $internalSubType): string + { + return match ($internalSubType) { + self::SUBTYPE_BUS => self::SUBTYPE_BUS_API, + self::SUBTYPE_CAR => self::SUBTYPE_CAR_API, + default => throw new \InvalidArgumentException("Unknown internal sub-type: $internalSubType"), + }; + } + + /** + * Checks if a sub-type is a bus service. + * + * @param string $subType The sub-type to check (API or internal format) + * + * @return bool True if the sub-type is a bus service, false otherwise + */ + public static function isBus(string $subType): bool + { + return in_array($subType, [self::SUBTYPE_BUS, self::SUBTYPE_BUS_API], true); + } + + /** + * Checks if a sub-type is a car service. + * + * @param string $subType The sub-type to check (API or internal format) + * + * @return bool True if the sub-type is a car service, false otherwise + */ + public static function isCar(string $subType): bool + { + return in_array($subType, [self::SUBTYPE_CAR, self::SUBTYPE_CAR_API], true); + } + + /** + * Validates that a transportation sub-type is recognized. + * + * @param string $subType The sub-type to validate + * + * @return bool True if the sub-type is valid, false otherwise + */ + public static function isValidSubType(string $subType): bool + { + return self::isBus($subType) || self::isCar($subType); + } } diff --git a/src/Form/BookingCreateParticipantType.php b/src/Form/BookingCreateParticipantType.php index f05aa59..56ae13e 100644 --- a/src/Form/BookingCreateParticipantType.php +++ b/src/Form/BookingCreateParticipantType.php @@ -178,6 +178,11 @@ class BookingCreateParticipantType extends AbstractType 'board', 'rentals', 'skiPass', + 'transportationOutbound', + 'transportationInbound', + 'pickupOutbound', + 'pickupInbound', + 'parking', ]; foreach ($dynamicFields as $fieldName) { @@ -209,7 +214,7 @@ class BookingCreateParticipantType extends AbstractType $this->addBaseFields($form, $bookingDto, $participantIndex); // Rebuild dynamic fields - $dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass']; + $dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass', 'transportationOutbound', 'transportationInbound', 'pickupOutbound', 'pickupInbound', 'parking']; foreach ($dynamicFields as $fieldName) { if ($form->has($fieldName)) { $form->remove($fieldName); @@ -233,6 +238,11 @@ class BookingCreateParticipantType extends AbstractType 'board' => ChoiceType::class, 'rentals' => ChoiceType::class, 'skiPass' => ChoiceType::class, + 'transportationOutbound' => ChoiceType::class, + 'transportationInbound' => ChoiceType::class, + 'pickupOutbound' => ChoiceType::class, + 'pickupInbound' => ChoiceType::class, + 'parking' => ChoiceType::class, ]; foreach ($dynamicFields as $fieldName => $fieldType) { diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 6a74feb..cc20e71 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -14,7 +14,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class BookingCreateStep2Type extends AbstractType { - public function __construct( private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry, ) { diff --git a/src/Form/RoomSelectType.php b/src/Form/RoomSelectType.php index 1de971e..c4c3a51 100644 --- a/src/Form/RoomSelectType.php +++ b/src/Form/RoomSelectType.php @@ -22,7 +22,7 @@ class RoomSelectType extends AbstractType $form = $event->getForm(); // Build label with pricing - $label = 'Anzahl ' . $data->roomLabel; + $label = 'Anzahl '.$data->roomLabel; if (null !== $options['room_price']) { $formattedPrice = number_format((float) $options['room_price'], 2, ',', '.'); $label .= sprintf(' (€%s pro Nacht)', $formattedPrice); diff --git a/src/Form/Service/Abstract/AbstractFieldStateProvider.php b/src/Form/Service/Abstract/AbstractFieldStateProvider.php index a1b11c1..2d69c1b 100644 --- a/src/Form/Service/Abstract/AbstractFieldStateProvider.php +++ b/src/Form/Service/Abstract/AbstractFieldStateProvider.php @@ -49,6 +49,7 @@ abstract class AbstractFieldStateProvider implements FieldStateProviderInterface } $hiddenCondition = $this->fieldStateConditions[$fieldName]['hidden']; + return !$hiddenCondition->evaluate($bookingDto, $participantIndex, $formData); } diff --git a/src/Form/Service/Condition/RoomSelectionCondition.php b/src/Form/Service/Condition/RoomSelectionCondition.php index f4cc9bd..e3a4bf2 100644 --- a/src/Form/Service/Condition/RoomSelectionCondition.php +++ b/src/Form/Service/Condition/RoomSelectionCondition.php @@ -87,6 +87,7 @@ class RoomSelectionCondition implements FieldConditionInterface public function getDescription(): string { $codes = implode(', ', $this->requiredRoomCodes); + return "Field visible when room with code(s) [{$codes}] is selected"; } diff --git a/src/Form/Service/Condition/ServiceSubTypeCondition.php b/src/Form/Service/Condition/ServiceSubTypeCondition.php new file mode 100644 index 0000000..2f8a6e4 --- /dev/null +++ b/src/Form/Service/Condition/ServiceSubTypeCondition.php @@ -0,0 +1,227 @@ +validateOperator($operator); + $this->validateExpectedSubType($operator, $expectedSubType); + } + + /** + * Evaluates the service sub-type condition against current form data. + * + * Retrieves the service from the specified field and checks its subType + * property against the expected value(s). Handles both form data and + * participant DTO data sources. + * + * @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 the service sub-type meets the condition criteria, false otherwise + */ + public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool + { + $service = $this->getService($formData, $participantIndex, $bookingDto); + + if (null === $service) { + // If no service is selected, condition fails + return false; + } + + $actualSubType = $service->subType ?? ''; + + return match ($this->operator) { + self::OPERATOR_EQUALS => $actualSubType === $this->expectedSubType, + self::OPERATOR_NOT_EQUALS => $actualSubType !== $this->expectedSubType, + self::OPERATOR_IN => $this->isSubTypeIn($actualSubType, $this->expectedSubType), + self::OPERATOR_NOT_IN => !$this->isSubTypeIn($actualSubType, $this->expectedSubType), + default => false, + }; + } + + /** + * Returns the field names that this condition depends on. + * + * This condition depends on the service field being evaluated, so any changes + * to that field should trigger re-evaluation of dependent field states. + * + * @return string[] Array containing the service field name being evaluated + */ + public function getDependentFields(): array + { + return [$this->serviceFieldName]; + } + + /** + * Returns a human-readable description of the service sub-type condition. + * + * Generates a descriptive string explaining what service field is being + * checked and what the expected sub-type criteria are. + * + * @return string Description of the service sub-type condition + */ + public function getDescription(): string + { + $subTypeDescription = is_array($this->expectedSubType) + ? '['.implode(', ', $this->expectedSubType).']' + : (string) $this->expectedSubType; + + return match ($this->operator) { + self::OPERATOR_EQUALS => sprintf('Service "%s" sub-type equals %s', $this->serviceFieldName, $subTypeDescription), + self::OPERATOR_NOT_EQUALS => sprintf('Service "%s" sub-type does not equal %s', $this->serviceFieldName, $subTypeDescription), + self::OPERATOR_IN => sprintf('Service "%s" sub-type is in %s', $this->serviceFieldName, $subTypeDescription), + self::OPERATOR_NOT_IN => sprintf('Service "%s" sub-type is not in %s', $this->serviceFieldName, $subTypeDescription), + default => sprintf('Service "%s" sub-type %s %s', $this->serviceFieldName, $this->operator, $subTypeDescription), + }; + } + + /** + * Creates a condition for service sub-type equality. + * + * @param string $serviceFieldName The service field to check + * @param string $expectedSubType The expected service sub-type + */ + public static function equals(string $serviceFieldName, string $expectedSubType): self + { + return new self($serviceFieldName, self::OPERATOR_EQUALS, $expectedSubType); + } + + /** + * Creates a condition for service sub-type non-equality. + * + * @param string $serviceFieldName The service field to check + * @param string $expectedSubType The sub-type that should not match + */ + public static function notEquals(string $serviceFieldName, string $expectedSubType): self + { + return new self($serviceFieldName, self::OPERATOR_NOT_EQUALS, $expectedSubType); + } + + /** + * Creates a condition for service sub-type inclusion. + * + * @param string $serviceFieldName The service field to check + * @param string[] $allowedSubTypes The allowed sub-types + */ + public static function in(string $serviceFieldName, array $allowedSubTypes): self + { + return new self($serviceFieldName, self::OPERATOR_IN, $allowedSubTypes); + } + + /** + * Creates a condition for service sub-type exclusion. + * + * @param string $serviceFieldName The service field to check + * @param string[] $excludedSubTypes The excluded sub-types + */ + public static function notIn(string $serviceFieldName, array $excludedSubTypes): self + { + return new self($serviceFieldName, self::OPERATOR_NOT_IN, $excludedSubTypes); + } + + /** + * Retrieves service from form data or participant data. + */ + private function getService(array $formData, int $participantIndex, BookingDtoInterface $bookingDto): ?Service + { + // First check participant-specific form data + if (isset($formData['participants'][$participantIndex][$this->serviceFieldName])) { + $serviceData = $formData['participants'][$participantIndex][$this->serviceFieldName]; + + // Form data might contain Service objects or service IDs + if ($serviceData instanceof Service) { + return $serviceData; + } + } + + // Then check participant DTO data + $participant = $bookingDto->getParticipant($participantIndex); + if (null !== $participant && property_exists($participant, $this->serviceFieldName)) { + return $participant->{$this->serviceFieldName}; + } + + return null; + } + + /** + * Checks if sub-type is in the expected array. + */ + private function isSubTypeIn(string $actualSubType, string|array $expectedSubTypes): bool + { + if (is_string($expectedSubTypes)) { + return $actualSubType === $expectedSubTypes; + } + + return in_array($actualSubType, $expectedSubTypes, true); + } + + /** + * Validates that the operator is supported. + */ + private function validateOperator(string $operator): void + { + $validOperators = [ + self::OPERATOR_EQUALS, + self::OPERATOR_NOT_EQUALS, + self::OPERATOR_IN, + self::OPERATOR_NOT_IN, + ]; + + if (!in_array($operator, $validOperators, true)) { + throw new \InvalidArgumentException(sprintf('Invalid operator "%s". Valid operators: %s', $operator, implode(', ', $validOperators))); + } + } + + /** + * Validates expected sub-type based on operator requirements. + */ + private function validateExpectedSubType(string $operator, string|array $expectedSubType): void + { + if (in_array($operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN], true) && !is_array($expectedSubType)) { + throw new \InvalidArgumentException(sprintf('Operator "%s" requires expectedSubType to be an array', $operator)); + } + + if (in_array($operator, [self::OPERATOR_EQUALS, self::OPERATOR_NOT_EQUALS], true) && !is_string($expectedSubType)) { + throw new \InvalidArgumentException(sprintf('Operator "%s" requires expectedSubType to be a string', $operator)); + } + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 7f8795d..258b465 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -4,11 +4,14 @@ declare(strict_types=1); namespace App\Form\Service; +use App\BusProNet\Utility\DirectionMapper; use App\Form\Service\Abstract\AbstractFieldStateProvider; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; +use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\RentalSelectionCondition; use App\Form\Service\Condition\RoomSelectionCondition; +use App\Form\Service\Condition\ServiceSubTypeCondition; /** * Field state provider for the booking create workflow. @@ -20,6 +23,8 @@ use App\Form\Service\Condition\RoomSelectionCondition; * Current field state conditions: * - Body dimension fields become required when rental services are selected * - Age-dependent service fields are hidden until birth date is provided + * - Transportation pickup fields are hidden when transportation type is not BUS + * - Parking field is hidden when outbound transportation is not PKW */ class CreateFieldStateProvider extends AbstractFieldStateProvider { @@ -92,6 +97,24 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider 'hidden' => CompositeCondition::not($mbzRoomCondition), ]; + // Transportation-related field conditions + + // Hide outbound pickup when transportation is not BUS + $this->fieldStateConditions['pickupOutbound'] = [ + 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API), + ]; + + // Hide inbound pickup when transportation is not BUS + $this->fieldStateConditions['pickupInbound'] = [ + 'hidden' => ServiceSubTypeCondition::notEquals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API), + ]; + + // Hide parking when outbound transportation is not PKW (car) + // Parking is offered at holiday destination for those arriving by car + $this->fieldStateConditions['parking'] = [ + 'hidden' => ServiceSubTypeCondition::notEquals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API), + ]; + // Example field state conditions would be registered here // For demonstration purposes, here are some example patterns: diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 1573245..ffc62bd 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace App\Form\Service; use App\BusProNet\Constants; +use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Service; +use App\BusProNet\Utility\DirectionMapper; use App\Form\Model\BookingCreateDto; use App\Form\Model\BookingDtoInterface; use App\Form\Service\Abstract\AbstractFieldOptionsProvider; @@ -187,6 +189,75 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ], ]; + // Transportation field providers - handles outbound/inbound transportation and pickup selection + + // Outbound Transportation + $this->fieldOptionProviders['transportationOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Hinfahrt', + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), + 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'attr' => [ + 'hx-post' => '#', // Will be configured when HTMX integration is implemented + 'hx-target' => '#booking-summary', + 'hx-trigger' => 'change', + ], + ]; + + // Inbound Transportation + $this->fieldOptionProviders['transportationInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Rückfahrt', + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), + 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => true, + 'attr' => [ + 'hx-post' => '#', // Will be configured when HTMX integration is implemented + 'hx-target' => '#booking-summary', + 'hx-trigger' => 'change', + ], + ]; + + // Outbound Pickup (conditional - only shown when outbound transportation is bus) + $this->fieldOptionProviders['pickupOutbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Zustieg Hinfahrt', + 'choices' => $bookingDto->travel->pickupsTo, + 'choice_label' => fn (Pickup $pickup) => $this->formatPickupLabel($pickup), + 'choice_value' => 'id', + 'expanded' => false, // Dropdown for pickups + 'multiple' => false, + 'required' => true, + 'placeholder' => 'Zustieg auswählen', + ]; + + // Inbound Pickup (conditional - only shown when inbound transportation is bus) + $this->fieldOptionProviders['pickupInbound'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Zustieg Rückfahrt', + 'choices' => $bookingDto->travel->pickupsFro, + 'choice_label' => fn (Pickup $pickup) => $this->formatPickupLabel($pickup), + 'choice_value' => 'id', + 'expanded' => false, + 'multiple' => false, + 'required' => true, + 'placeholder' => 'Zustieg auswählen', + ]; + + // Parking (conditional - only shown when at least one transportation direction is PKW) + $this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ + 'label' => 'Parkplatz', + 'choices' => $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true), + 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_value' => 'id', + 'expanded' => true, + 'multiple' => false, + 'required' => false, + ]; + // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ @@ -219,6 +290,73 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.')); } + /** + * Format transportation service labels with type indicator and pricing. + * + * Creates user-friendly labels for transportation services that include: + * - Transportation type icon (🚌 for bus, 🚗 for car) + * - Service name + * - Pricing (with discount indication for negative prices) + * - Availability warning for limited services + * + * @param Service $service The transportation service to format + * + * @return string The formatted transportation service label + */ + private function formatTransportationServiceLabel(Service $service): string + { + $label = $service->label; + + // Add transportation type indicator + $typeIndicator = match ($service->subType) { + 'BUS' => '🚌', + 'PKW' => '🚗', + default => '', + }; + + if ($typeIndicator) { + $label = $typeIndicator.' '.$label; + } + + // Add pricing with discount indication + if (null !== $service->price) { + if ($service->price > 0) { + $label .= sprintf(' (+€%.2f)', $service->price); + } elseif ($service->price < 0) { + $label .= sprintf(' (-€%.2f Discount)', abs($service->price)); + } + } + + // Add availability warning if limited + if (null !== $service->available && $service->available <= 5) { + $label .= sprintf(' (nur %d verfügbar)', $service->available); + } + + return $label; + } + + /** + * Format pickup labels with city and street information. + * + * Creates user-friendly labels for pickup locations following the existing pattern: + * - Primary format: "City (Street)" if street is available + * - Fallback format: "City" if no street information + * + * @param Pickup $pickup The pickup location to format + * + * @return string The formatted pickup location label + */ + private function formatPickupLabel(Pickup $pickup): string + { + $label = $pickup->city ?? ''; + + if (null !== $pickup->street && '' !== trim($pickup->street)) { + $label .= ' ('.$pickup->street.')'; + } + + return $label; + } + /** * Filters services based on participant's age constraints. * diff --git a/src/Form/Service/ParticipantParkingFieldHandler.php b/src/Form/Service/ParticipantParkingFieldHandler.php new file mode 100644 index 0000000..24ea3c2 --- /dev/null +++ b/src/Form/Service/ParticipantParkingFieldHandler.php @@ -0,0 +1,115 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for parking selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process for state changes + } + + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Check if parking is applicable (at least one PKW direction) + if (!$this->isParkingApplicable($participant)) { + $participant->parking = null; // Clear parking for bus-only transport + + return; + } + + $selectedParking = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available parking services (subtype PAR) + $availableParkingServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true); + + $validSelection = null; + if (null !== $selectedParking) { + $validSelection = $this->findValidParkingService($selectedParking, $availableParkingServices); + } + + $participant->parking = $validSelection; + } + + /** + * Checks if parking is applicable based on transportation selections. + * + * @param object $participant The participant DTO + * + * @return bool True if parking is applicable, false otherwise + */ + private function isParkingApplicable(object $participant): bool + { + $outboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationOutbound?->subType; + $inboundIsPkw = DirectionMapper::SUBTYPE_CAR_API === $participant->transportationInbound?->subType; + + return $outboundIsPkw || $inboundIsPkw; + } + + /** + * Finds a valid parking service from available parking services. + * + * @param mixed $selectedServiceId The submitted service ID + * @param array $availableServices Array of available parking services + * + * @return Service|null The valid service object, or null if invalid + */ + private function findValidParkingService(mixed $selectedServiceId, array $availableServices): ?Service + { + if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) { + return null; + } + + $serviceId = (int) $selectedServiceId; + + foreach ($availableServices as $service) { + if ($service->id === $serviceId) { + return $service; + } + } + + return null; + } +} diff --git a/src/Form/Service/ParticipantPickupInboundFieldHandler.php b/src/Form/Service/ParticipantPickupInboundFieldHandler.php new file mode 100644 index 0000000..74ecccf --- /dev/null +++ b/src/Form/Service/ParticipantPickupInboundFieldHandler.php @@ -0,0 +1,97 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for pickup selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle clearing pickup + } + + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Only process pickup if inbound transportation is bus + if (null === $participant->transportationInbound || DirectionMapper::SUBTYPE_BUS_API !== $participant->transportationInbound->subType) { + $participant->pickupInbound = null; // Clear pickup for non-bus transport + + return; + } + + $selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Validate pickup selection against available inbound pickups + $validSelection = null; + if (null !== $selectedPickup) { + $validSelection = $this->findValidPickup($selectedPickup, $bookingDto->travel->pickupsFro); + } + + $participant->pickupInbound = $validSelection; + } + + /** + * Finds a valid pickup from available pickups. + * + * @param mixed $selectedPickupId The submitted pickup ID + * @param array $availablePickups Array of available pickup locations + * + * @return Pickup|null The valid pickup object, or null if invalid + */ + private function findValidPickup(mixed $selectedPickupId, array $availablePickups): ?Pickup + { + if (null === $selectedPickupId || false === is_string($selectedPickupId) && false === is_int($selectedPickupId)) { + return null; + } + + $pickupId = (int) $selectedPickupId; + + foreach ($availablePickups as $pickup) { + if ($pickup->id === $pickupId) { + return $pickup; + } + } + + return null; + } +} diff --git a/src/Form/Service/ParticipantPickupOutboundFieldHandler.php b/src/Form/Service/ParticipantPickupOutboundFieldHandler.php new file mode 100644 index 0000000..85a0c9b --- /dev/null +++ b/src/Form/Service/ParticipantPickupOutboundFieldHandler.php @@ -0,0 +1,99 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for pickup selection fields + */ + public function shouldProcess(array $submittedData, int $participantIndex): bool + { + return true; // Always process to handle clearing pickup + } + + public function processField(array $submittedData, BookingDtoInterface $bookingDto, int $participantIndex): void + { + $participant = $this->getParticipant($bookingDto, $participantIndex); + if (null === $participant) { + return; + } + + // Only process pickup if outbound transportation is bus + if (null === $participant->transportationOutbound || DirectionMapper::SUBTYPE_BUS_API !== $participant->transportationOutbound->subType) { + $participant->pickupOutbound = null; // Clear pickup for non-bus transport + $participant->pickup = null; // Backward compatibility + + return; + } + + $selectedPickup = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Validate pickup selection against available outbound pickups + $validSelection = null; + if (null !== $selectedPickup) { + $validSelection = $this->findValidPickup($selectedPickup, $bookingDto->travel->pickupsTo); + } + + $participant->pickupOutbound = $validSelection; + $participant->pickup = $validSelection; // Backward compatibility + } + + /** + * Finds a valid pickup from available pickups. + * + * @param mixed $selectedPickupId The submitted pickup ID + * @param array $availablePickups Array of available pickup locations + * + * @return Pickup|null The valid pickup object, or null if invalid + */ + private function findValidPickup(mixed $selectedPickupId, array $availablePickups): ?Pickup + { + if (null === $selectedPickupId || false === is_string($selectedPickupId) && false === is_int($selectedPickupId)) { + return null; + } + + $pickupId = (int) $selectedPickupId; + + foreach ($availablePickups as $pickup) { + if ($pickup->id === $pickupId) { + return $pickup; + } + } + + return null; + } +} diff --git a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php index d2fa113..929ad18 100644 --- a/src/Form/Service/ParticipantRemarksRoomFieldHandler.php +++ b/src/Form/Service/ParticipantRemarksRoomFieldHandler.php @@ -58,4 +58,4 @@ class ParticipantRemarksRoomFieldHandler extends AbstractParticipantFieldHandler // 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/ParticipantSkiPassFieldHandler.php b/src/Form/Service/ParticipantSkiPassFieldHandler.php index 05efa0e..682247f 100644 --- a/src/Form/Service/ParticipantSkiPassFieldHandler.php +++ b/src/Form/Service/ParticipantSkiPassFieldHandler.php @@ -14,7 +14,7 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler; * * This handler manages skipass selection for participants in the booking * creation process. It processes the skiPass field from form submissions, - * validates age-appropriate and date-valid skipasses, and updates the + * validates age-appropriate and date-valid skipasses, and updates the * participant DTO with the valid selection. * * Key responsibilities: @@ -109,7 +109,6 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler $participant->skiPass = $validSelection; } - /** * Validates if a selected skipass is still valid for the participant. * @@ -148,7 +147,7 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler // 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 } @@ -203,4 +202,4 @@ class ParticipantSkiPassFieldHandler extends AbstractParticipantFieldHandler return false; } -} \ No newline at end of file +} diff --git a/src/Form/Service/ParticipantTransportationInboundFieldHandler.php b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php new file mode 100644 index 0000000..5cdcdfa --- /dev/null +++ b/src/Form/Service/ParticipantTransportationInboundFieldHandler.php @@ -0,0 +1,101 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for transportation 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); + if (null === $participant) { + return; + } + + $selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available inbound transportation services + $availableServices = $bookingDto->travel->getTransportationServicesByDirection( + DirectionMapper::INBOUND_TRAVEL, + true // filter available + ); + + // Validate and convert selection to Service object + $validSelection = null; + if (null !== $selectedTransportation) { + $validSelection = $this->findValidTransportationService( + $selectedTransportation, + $availableServices + ); + } + + // Update participant with validated selection + $participant->transportationInbound = $validSelection; + + // Backward compatibility: also update deprecated property + $participant->transportationServiceFro = $validSelection; + } + + /** + * Finds a valid transportation service from available services. + * + * @param mixed $selectedServiceId The submitted service ID + * @param array $availableServices Array of available transportation services + * + * @return Service|null The valid service object, or null if invalid + */ + private function findValidTransportationService( + mixed $selectedServiceId, + array $availableServices, + ): ?Service { + if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) { + return null; + } + + $serviceId = (int) $selectedServiceId; + + foreach ($availableServices as $service) { + if ($service->id === $serviceId) { + return $service; + } + } + + return null; + } +} diff --git a/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php new file mode 100644 index 0000000..21c1833 --- /dev/null +++ b/src/Form/Service/ParticipantTransportationOutboundFieldHandler.php @@ -0,0 +1,101 @@ + $submittedData The submitted participant form data + * @param int $participantIndex The index of the participant being processed + * + * @return bool Always returns true for transportation 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); + if (null === $participant) { + return; + } + + $selectedTransportation = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Get available outbound transportation services + $availableServices = $bookingDto->travel->getTransportationServicesByDirection( + DirectionMapper::OUTBOUND_TRAVEL, + true // filter available + ); + + // Validate and convert selection to Service object + $validSelection = null; + if (null !== $selectedTransportation) { + $validSelection = $this->findValidTransportationService( + $selectedTransportation, + $availableServices + ); + } + + // Update participant with validated selection + $participant->transportationOutbound = $validSelection; + + // Backward compatibility: also update deprecated property + $participant->transportationServiceTo = $validSelection; + } + + /** + * Finds a valid transportation service from available services. + * + * @param mixed $selectedServiceId The submitted service ID + * @param array $availableServices Array of available transportation services + * + * @return Service|null The valid service object, or null if invalid + */ + private function findValidTransportationService( + mixed $selectedServiceId, + array $availableServices, + ): ?Service { + if (null === $selectedServiceId || false === is_string($selectedServiceId) && false === is_int($selectedServiceId)) { + return null; + } + + $serviceId = (int) $selectedServiceId; + + foreach ($availableServices as $service) { + if ($service->id === $serviceId) { + return $service; + } + } + + return null; + } +}