diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 75ca8d6..b66107d 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -97,18 +97,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => true, 'expanded' => true, 'required' => false, - 'choices' => $this->filterServicesByAvailability( - $this->filterServicesByAgeConstraints( - $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), - $bookingDto, - $participantIndex - ), + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), - 'choice_attr' => function (?Service $service) { + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } @@ -120,6 +116,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + return $attributes; }, ]; @@ -130,18 +132,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => true, 'expanded' => true, 'required' => false, - 'choices' => $this->filterServicesByAvailability( - $this->filterServicesByAgeConstraints( - $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), - $bookingDto, - $participantIndex - ), + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), - 'choice_attr' => function (?Service $service) { + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } @@ -160,6 +158,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } + // Make readonly if service is unavailable (only if not already mandatory) + if (false === $service->mandatory && $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + return $attributes; }, ]; @@ -170,17 +174,28 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => true, 'expanded' => true, 'required' => false, - 'choices' => $this->filterServicesByAvailability( - $this->filterServicesByAgeConstraints( - $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), - $bookingDto, - $participantIndex - ), + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } + + $attributes = []; + + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, ]; // Rentals field provider - provides age-appropriate rental options filtered by selected skipass duration @@ -189,13 +204,9 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => true, 'expanded' => true, 'required' => false, - 'choices' => $this->filterServicesByAvailability( - $this->filterServicesByAgeConstraints( - $this->filterRentalsBySkiPassDuration( - $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true), - $bookingDto, - $participantIndex - ), + 'choices' => $this->filterServicesByAgeConstraints( + $this->filterRentalsBySkiPassDuration( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true), $bookingDto, $participantIndex ), @@ -204,7 +215,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), - 'choice_attr' => function (?Service $service) { + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } @@ -216,6 +227,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + return $attributes; }, ]; @@ -246,18 +263,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider 'multiple' => false, 'expanded' => true, 'required' => true, - 'choices' => $this->filterServicesByAvailability( - $this->filterServicesByAgeConstraints( - $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true), - $bookingDto, - $participantIndex - ), + 'choices' => $this->filterServicesByAgeConstraints( + $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true), $bookingDto, $participantIndex ), 'choice_value' => 'id', 'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service), - 'choice_attr' => function (?Service $service) { + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { if (null === $service) { return []; } @@ -269,6 +282,12 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider $attributes['data-description'] = $service->description; } + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + return $attributes; }, ]; @@ -288,16 +307,27 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // 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 - ), + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::OUTBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } + + $attributes = []; + + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, 'attr' => [ 'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-target' => '#booking-summary', @@ -308,16 +338,27 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // 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 - ), + 'choices' => $bookingDto->travel->getTransportationServicesByDirection(DirectionMapper::INBOUND_TRAVEL), 'choice_label' => fn (Service $service) => $this->formatTransportationServiceLabel($service), 'choice_value' => 'id', 'expanded' => true, 'multiple' => false, 'required' => true, + 'choice_attr' => function (?Service $service) use ($bookingDto, $participantIndex) { + if (null === $service) { + return []; + } + + $attributes = []; + + // Make readonly if service is unavailable + if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) { + $attributes['readonly'] = true; + $attributes['data-tooltip'] = 'ausgebucht'; + } + + return $attributes; + }, 'attr' => [ 'hx-post' => '#', // Will be configured when HTMX integration is implemented 'hx-target' => '#booking-summary', @@ -553,6 +594,25 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider return $this->serviceAvailabilityCalculator->filterAvailableServices($services, $bookingDto, $participantIndex); } + /** + * Checks if a service should be rendered as read-only due to unavailability. + * + * @param Service $service The service to check + * @param BookingDtoInterface $bookingDto The booking DTO containing participant data + * @param int $participantIndex Index of the participant currently selecting services + * + * @return bool True if the service should be read-only due to unavailability + */ + private function isServiceUnavailableForParticipant(Service $service, BookingDtoInterface $bookingDto, int $participantIndex): bool + { + if (!$bookingDto instanceof BookingCreateDto) { + // For non-create workflows, don't apply availability restrictions + return false; + } + + return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex); + } + /** * Filters services based on participant's age constraints. * diff --git a/src/Service/ServiceAvailabilityCalculator.php b/src/Service/ServiceAvailabilityCalculator.php index 81f28ce..7b41bc4 100644 --- a/src/Service/ServiceAvailabilityCalculator.php +++ b/src/Service/ServiceAvailabilityCalculator.php @@ -74,19 +74,34 @@ class ServiceAvailabilityCalculator } /** - * Check if a specific service is available for the current participant. + * Check if a specific service is unavailable (sold out) for the current participant. * * @param int $serviceId The ID of the service to check * @param BookingCreateDto $bookingDto The booking data with participant selections * @param int $participantIndex The index of the participant currently filling the form * - * @return bool True if the service has remaining availability + * @return bool True if the service is unavailable (has availability limit and remaining is 0) */ - public function isServiceAvailable(int $serviceId, BookingCreateDto $bookingDto, int $participantIndex): bool + public function isServiceUnavailable(int $serviceId, BookingCreateDto $bookingDto, int $participantIndex): bool { + $allServices = $this->getAllServicesFromTravel($bookingDto); + $service = null; + + foreach ($allServices as $serviceObj) { + if ($serviceObj->id === $serviceId) { + $service = $serviceObj; + break; + } + } + + // If service not found or has no availability limit, it's not unavailable + if (null === $service || null === $service->available || $service->available <= 0) { + return false; + } + $remainingAvailability = $this->calculateRemainingAvailability($bookingDto, $participantIndex); - return ($remainingAvailability[$serviceId] ?? 0) > 0; + return ($remainingAvailability[$serviceId] ?? $service->available) <= 0; } /**