fieldOptionProviders['newField'] = fn($bookingDto, $participantIndex) => [ * 'label' => 'New Field Label', * 'choices' => $this->generateChoicesFor($bookingDto, $participantIndex), * ]; * * Provider Pattern Benefits: * - Lazy evaluation (options only generated when needed) * - Context-aware configuration * - Easy to test individual field logic * - Supports complex interdependencies */ protected function registerFieldOptionProviders(): void { // Room assignment field provider (only available for create workflow) $this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zimmer', 'placeholder' => 'Bitte wählen', // Use factory to create context-aware choice loader that: // - Shows only available rooms for this participant // - Excludes rooms already assigned to other participants // - Respects room capacity and booking constraints 'choice_loader' => $bookingDto instanceof BookingCreateDto ? $this->roomChoiceLoaderFactory->create( $bookingDto->participants, $bookingDto->getSelectedRooms(), $participantIndex ) : null, ]; // Courses field provider - provides age-appropriate courses from travel data $this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kurse', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAvailability( $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } return $attributes; }, ]; // Additional services field provider - provides age-appropriate additional services with mandatory pre-selection $this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Zusatzleistungen', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAvailability( $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; } $attributes = []; // Make readonly for mandatory services (pre-selection handled by service layer) if (true === $service->mandatory) { $attributes['readonly'] = true; $attributes['class'] = 'text-pink'; $attributes['title'] = 'Diese Leistung ist nicht abwählbar'; } // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } return $attributes; }, ]; // Board field provider - provides age-appropriate board options from travel data $this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Verpflegung', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAvailability( $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), ]; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration $this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Leihmaterial', 'multiple' => true, 'expanded' => true, 'required' => false, 'choices' => $this->filterServicesByAvailability( $this->filterServicesByAgeConstraints( $this->filterRentalsBySkiPassDuration( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } return $attributes; }, ]; // Rental insurance field provider - provides rental insurance options when rental services are selected $this->fieldOptionProviders['rentalInsurance'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getRentalInsuranceCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)), 'required' => false, 'property_path' => 'rentalInsuranceSelected', 'help' => $this->getRentalInsuranceDescription($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTAL_INSURANCE, true, true)), ]; // License plate field provider - provides text input for vehicle license plate when parking is selected $this->fieldOptionProviders['licensePlate'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Kennzeichen', 'required' => false, 'attr' => [ 'placeholder' => 'z.B. AB-CD 123', 'maxlength' => 20, ], 'help' => 'Bitte gib das Kennzeichen deines Fahrzeugs an. Du kannst es aber auch später nachreichen.', 'clean_xss' => true, ]; // Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range $this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Skipass', 'multiple' => false, 'expanded' => true, 'required' => true, 'choices' => $this->filterServicesByAvailability( $this->filterServicesByAgeConstraints( $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true), $bookingDto, $participantIndex ), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), 'choice_attr' => function (?Service $service) { if (null === $service) { return []; } $attributes = []; // Add service description as data attribute for frontend use if (null !== $service->description && '' !== trim($service->description)) { $attributes['data-description'] = $service->description; } return $attributes; }, ]; // Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms) $this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => 'Wünsche oder Anmerkungen zum Zimmer', 'required' => false, 'clean_xss' => true, 'attr' => [ 'rows' => 2, ], ]; // 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' => $this->filterServicesByAvailability( $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), $bookingDto, $participantIndex ), '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' => $this->filterServicesByAvailability( $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), $bookingDto, $participantIndex ), '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->formatPickupLabelWithPrice($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' => 'Ausstieg Rückfahrt', 'choices' => $bookingDto->travel->pickupsFro, 'choice_label' => fn (?Pickup $pickup) => $this->formatPickupLabelWithPrice($pickup), 'choice_value' => 'id', 'expanded' => false, 'multiple' => false, 'required' => true, 'placeholder' => 'Ausstieg auswählen', ]; // Parking (conditional - only shown when outbound transportation is PKW) // Simple checkbox since there's only ever one parking type $this->fieldOptionProviders['parking'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [ 'label' => $this->getParkingCheckboxLabel($bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_PARKING, true)), 'required' => false, ]; // Future field providers would be added here, for example: // // $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [ // 'label' => 'Meal Preference', // 'choices' => [ // 'Standard' => 'standard', // 'Vegetarian' => 'vegetarian', // 'Vegan' => 'vegan', // ], // ]; } /** * Filters rental services by selected skipass duration. * * Only returns rentals that have exactly the same dateFrom and dateTo * as the participant's selected skipass. This ensures rental equipment * is only available for the exact duration of the skipass. * * @param array $rentals Array of rental Service objects to filter * @param BookingDtoInterface $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant to evaluate * * @return array Filtered array of rentals matching skipass duration */ private function filterRentalsBySkiPassDuration(array $rentals, BookingDtoInterface $bookingDto, int $participantIndex): array { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant || null === $participant->skiPass) { return []; // No skipass selected = no rentals available } $selectedSkiPass = $participant->skiPass; // If skipass has no valid dates, return empty rentals if (null === $selectedSkiPass->dateFrom || null === $selectedSkiPass->dateTo) { return []; } return array_filter($rentals, function (Service $rental) use ($selectedSkiPass) { // Rental must have valid dates to be considered if (null === $rental->dateFrom || null === $rental->dateTo) { return false; } // Exact date matching: rental dates must match skipass dates exactly return $rental->dateFrom->format('Y-m-d') === $selectedSkiPass->dateFrom->format('Y-m-d') && $rental->dateTo->format('Y-m-d') === $selectedSkiPass->dateTo->format('Y-m-d'); }); } /** * Formats service label with pricing information. * * @param Service|null $service The service to format * * @return string The formatted label */ private function formatServiceLabelWithPrice(?Service $service): string { if (null === $service) { return ''; } if (null === $service->price || 0.0 === $service->price) { return $service->label; } if ($service->price < 0) { // Negative prices are discounts return sprintf('%s (-%s€ Rabatt)', $service->label, number_format(abs($service->price), 2, ',', '.')); } 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; if (null === $service->price || 0.0 === $service->price) { return $service->label; } if ($service->price > 0) { $label .= sprintf(' (€%s)', number_format($service->price, 2, ',', '.')); } else { $label .= sprintf(' (-%s€ Rabatt)', number_format(abs($service->price), 2, ',', '.')); } // 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; } private function formatPickupLabelWithPrice(?Pickup $pickup): string { if (null === $pickup) { return ''; } $label = $this->formatPickupLabel($pickup); if (null === $pickup->price || 0.0 === $pickup->price) { return $label; } if ($pickup->price < 0) { // Negative prices are discounts return sprintf('%s (-%s€ Rabatt)', $label, number_format(abs($pickup->price), 2, ',', '.')); } return sprintf('%s (€%s)', $label, number_format($pickup->price, 2, ',', '.')); } /** * Gets the parking checkbox label with pricing information. * * Creates a checkbox label for the single parking service including pricing. * Since there's only ever one parking type, we take the first available service. * * @param array $parkingServices Array of available parking services * * @return string The formatted checkbox label with pricing */ private function getParkingCheckboxLabel(array $parkingServices): string { if (empty($parkingServices)) { return 'Parkplatz'; } $parkingService = reset($parkingServices); // Get the first (and only) parking service return $this->formatServiceLabelWithPrice($parkingService); } /** * Filters services based on remaining availability in the current booking session. * * Removes services that have been fully booked by other participants in * the current booking session. This prevents overbooking within a single * booking workflow while maintaining accurate availability counts. * * @param array $services Array of Service objects to filter * @param BookingDtoInterface $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant currently selecting services * * @return array Filtered array containing only services with remaining availability */ private function filterServicesByAvailability(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array { if (!$bookingDto instanceof BookingCreateDto) { // For non-create workflows, return all services (no availability tracking needed) return $services; } return $this->serviceAvailabilityCalculator->filterAvailableServices($services, $bookingDto, $participantIndex); } /** * Filters services based on participant's age constraints. * * Removes services that have age restrictions the participant doesn't meet. * If no age evaluator is configured or participant has no birth date, * returns empty array to be handled by field visibility conditions. * * @param array $services Array of Service objects to filter * @param BookingDtoInterface $bookingDto The booking DTO containing participant data * @param int $participantIndex Index of the participant to evaluate * * @return array Filtered array of available services */ private function filterServicesByAgeConstraints(array $services, BookingDtoInterface $bookingDto, int $participantIndex): array { $ageEvaluator = new ServiceAgeEvaluator(); $participant = $bookingDto->getParticipant($participantIndex); // If no birthdate provided, return empty array (handled by field visibility conditions) if (null === $participant || null === $participant->dateOfBirth) { return []; } return array_filter($services, function (Service $service) use ($bookingDto, $participantIndex, $ageEvaluator) { // No age constraints = available to all if (false === $ageEvaluator->canEvaluate($service)) { return true; } return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex); }); } /** * Generates label for rental insurance checkbox including pricing information. */ private function getRentalInsuranceCheckboxLabel(array $rentalInsuranceServices): string { if (empty($rentalInsuranceServices)) { return 'Leihmaterial-Versicherung'; } $rentalInsuranceService = reset($rentalInsuranceServices); // Get the first (and only) rental insurance service return $this->formatServiceLabelWithPrice($rentalInsuranceService); } /** * Gets the rental insurance description for help text. */ private function getRentalInsuranceDescription(array $rentalInsuranceServices): ?string { if (empty($rentalInsuranceServices)) { return null; } $rentalInsuranceService = reset($rentalInsuranceServices); // Get the first (and only) rental insurance service return $rentalInsuranceService->description; } }