feat: refactor to cards
This commit is contained in:
@@ -1,81 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\ParticipantFieldHandlerRegistry;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type for Step 2 of the booking process (participant data validation).
|
||||
*
|
||||
* This form is used in the card-based UI where participants are edited individually
|
||||
* in separate forms. This form validates the complete BookingDto before proceeding
|
||||
* to Step 3, ensuring all participants have valid and complete data.
|
||||
*/
|
||||
class BookingCreateStep2Type extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData'])
|
||||
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the initial form creation.
|
||||
*/
|
||||
public function onPreSetData(FormEvent $event): void
|
||||
{
|
||||
/** @var BookingDto|null $data */
|
||||
$data = $event->getData();
|
||||
if (null === $data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addParticipantsField($event->getForm());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dynamic participant form field updates on POST requests (e.g., from HTMX).
|
||||
*
|
||||
* This listener synchronizes the BookingDto with the submitted participant data *before*
|
||||
* the form's children are processed. It then rebuilds the participants
|
||||
* field to ensure choice loaders are created with the fresh state.
|
||||
*/
|
||||
public function onPreSubmit(FormEvent $event): void
|
||||
{
|
||||
$form = $event->getForm();
|
||||
$submittedData = $event->getData();
|
||||
|
||||
/** @var BookingDto $bookingDto */
|
||||
$bookingDto = $form->getData();
|
||||
|
||||
// Process field handlers and synchronize submitted data with cleaned DTO state
|
||||
$cleanedSubmittedData = $this->participantFieldHandlerRegistry->processFieldsAndSync($submittedData, $bookingDto);
|
||||
$event->setData($cleanedSubmittedData);
|
||||
|
||||
// Rebuild the 'participants' field with the updated DTO.
|
||||
$this->addParticipantsField($form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds or replaces the 'participants' collection field on the form.
|
||||
*/
|
||||
private function addParticipantsField(FormInterface $form): void
|
||||
{
|
||||
$form->add('participants', CollectionType::class, [
|
||||
'entry_type' => BookingParticipantType::class,
|
||||
'entry_options' => [
|
||||
'edit_mode' => false,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
]);
|
||||
// No fields needed - participants are edited individually in their own forms
|
||||
// This form exists purely for validation and CSRF protection
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -1,71 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Service\ParticipantFieldHandlerRegistry;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Form type for edit booking validation.
|
||||
*
|
||||
* This form is used in the card-based UI where participants are edited individually.
|
||||
* This form validates the complete BookingDto before allowing updates,
|
||||
* ensuring all participants have valid and complete data.
|
||||
*/
|
||||
class BookingEditType extends AbstractType
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData'])
|
||||
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
|
||||
}
|
||||
|
||||
public function onPreSetData(FormEvent $event): void
|
||||
{
|
||||
/** @var BookingDto $data */
|
||||
$data = $event->getData();
|
||||
$form = $event->getForm();
|
||||
|
||||
$form->add('participants', CollectionType::class, [
|
||||
'entry_type' => BookingParticipantType::class,
|
||||
'entry_options' => [
|
||||
'edit_mode' => true,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onPreSubmit(FormEvent $event): void
|
||||
{
|
||||
$form = $event->getForm();
|
||||
$submittedData = $event->getData();
|
||||
|
||||
/** @var BookingDto $bookingDto */
|
||||
$bookingDto = $form->getData();
|
||||
|
||||
// Process field handlers and synchronize submitted data with cleaned DTO state
|
||||
$cleanedSubmittedData = $this->participantFieldHandlerRegistry->processFieldsAndSync($submittedData, $bookingDto);
|
||||
$event->setData($cleanedSubmittedData);
|
||||
|
||||
// Rebuild the 'participants' field with the updated DTO
|
||||
if ($form->has('participants')) {
|
||||
$form->remove('participants');
|
||||
}
|
||||
|
||||
$form->add('participants', CollectionType::class, [
|
||||
'entry_type' => BookingParticipantType::class,
|
||||
'entry_options' => [
|
||||
'edit_mode' => true,
|
||||
],
|
||||
'allow_add' => false,
|
||||
'allow_delete' => false,
|
||||
]);
|
||||
// No fields needed - participants are edited individually in their own forms
|
||||
// This form exists purely for validation
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -9,12 +9,12 @@ use App\Form\Service\Contract\FieldOptionsProviderInterface;
|
||||
use App\Form\Service\Contract\FieldStateProviderInterface;
|
||||
use App\Form\Service\CreateFieldStateProvider;
|
||||
use App\Form\Service\EditFieldStateProvider;
|
||||
use App\Form\Service\ParticipantFieldHandlerRegistry;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -31,6 +31,7 @@ class BookingParticipantType extends AbstractType
|
||||
private readonly FieldOptionsProviderInterface $fieldOptionsProvider,
|
||||
private readonly CreateFieldStateProvider $createFieldStateProvider,
|
||||
private readonly EditFieldStateProvider $editFieldStateProvider,
|
||||
private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -41,19 +42,60 @@ class BookingParticipantType extends AbstractType
|
||||
? $this->editFieldStateProvider
|
||||
: $this->createFieldStateProvider;
|
||||
|
||||
// Capture booking context for use in event listeners
|
||||
$bookingContext = $options['booking_context'];
|
||||
|
||||
$builder
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
|
||||
$this->onPreSetData($event);
|
||||
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($bookingContext) {
|
||||
$this->onPreSetData($event, $bookingContext);
|
||||
})
|
||||
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
|
||||
$this->onPreSubmit($event);
|
||||
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) use ($bookingContext) {
|
||||
// Process field handlers FIRST (before form binding and validation)
|
||||
// This ensures data is cleaned before Symfony processes it
|
||||
if (null !== $bookingContext) {
|
||||
$this->processFieldHandlers($event, $bookingContext);
|
||||
}
|
||||
|
||||
// Then rebuild fields with updated states
|
||||
$this->onPreSubmit($event, $bookingContext);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes field handlers for this participant.
|
||||
*
|
||||
* Field handlers are executed in PRE_SUBMIT to clean and transform data
|
||||
* before Symfony binds it to the form. This matches the pattern used in
|
||||
* the old BookingCreateStep2Type parent form.
|
||||
*/
|
||||
private function processFieldHandlers(FormEvent $event, BookingDto $bookingContext): void
|
||||
{
|
||||
$form = $event->getForm();
|
||||
$submittedData = $event->getData();
|
||||
|
||||
if (false === is_array($submittedData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ParticipantDto $participant */
|
||||
$participant = $form->getData();
|
||||
|
||||
if (null === $participant || false === property_exists($participant, 'index')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process all field handlers for this participant in dependency order
|
||||
$this->fieldHandlerRegistry->processFieldsForParticipant(
|
||||
$submittedData,
|
||||
$bookingContext,
|
||||
$participant->index
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds dynamic fields to the form based on participant data.
|
||||
*/
|
||||
private function onPreSetData(FormEvent $event): void
|
||||
private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void
|
||||
{
|
||||
/** @var ParticipantDto|null $participantData */
|
||||
$participantData = $event->getData();
|
||||
@@ -63,8 +105,9 @@ class BookingParticipantType extends AbstractType
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the booking DTO from the root form
|
||||
$bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form);
|
||||
// Card flow: BookingDto passed via options
|
||||
// Accordion flow (if we had one): traverse form tree
|
||||
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
|
||||
|
||||
if (null === $bookingDto) {
|
||||
return;
|
||||
@@ -80,7 +123,7 @@ class BookingParticipantType extends AbstractType
|
||||
/**
|
||||
* Handles form pre-submit events to update field states based on submitted data.
|
||||
*/
|
||||
private function onPreSubmit(FormEvent $event): void
|
||||
private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void
|
||||
{
|
||||
$submittedData = $event->getData();
|
||||
$form = $event->getForm();
|
||||
@@ -89,8 +132,9 @@ class BookingParticipantType extends AbstractType
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the booking DTO from the root form
|
||||
$bookingDto = $this->fieldStateProvider->getBookingDtoFromForm($form);
|
||||
// Card flow: BookingDto passed via options
|
||||
// Accordion flow (if we had one): traverse form tree
|
||||
$bookingDto = $bookingContext ?? $this->fieldStateProvider->getBookingDtoFromForm($form);
|
||||
|
||||
if (null === $bookingDto) {
|
||||
return;
|
||||
@@ -173,7 +217,7 @@ class BookingParticipantType extends AbstractType
|
||||
* field states change based on submitted data.
|
||||
*
|
||||
* @param FormInterface $form The form to modify
|
||||
* @param BookingDto $bookingDto The booking data for context
|
||||
* @param BookingDto $bookingDto The booking data for context
|
||||
* @param int $participantIndex The participant index
|
||||
* @param array<string, mixed> $formData Submitted form data for state calculation
|
||||
*/
|
||||
@@ -334,9 +378,11 @@ class BookingParticipantType extends AbstractType
|
||||
'data_class' => ParticipantDto::class,
|
||||
'selected_rooms' => [],
|
||||
'edit_mode' => false,
|
||||
'booking_context' => null,
|
||||
]);
|
||||
|
||||
$resolver->setAllowedTypes('selected_rooms', 'array');
|
||||
$resolver->setAllowedTypes('edit_mode', 'bool');
|
||||
$resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ class BookingDto
|
||||
|
||||
/**
|
||||
* Booking status code for API submission.
|
||||
* Values: 'F' (Final/Frei), 'A' (Anfrage/Inquiry)
|
||||
* Values: 'F' (Final/Frei), 'A' (Anfrage/Inquiry).
|
||||
*/
|
||||
public string $bookingStatus = 'F';
|
||||
|
||||
@@ -64,6 +64,13 @@ class BookingDto
|
||||
*/
|
||||
public ?\DateTimeImmutable $lastSessionUpdate = null;
|
||||
|
||||
/**
|
||||
* Fingerprint of the booking state when loaded from API (edit mode only).
|
||||
* This property stores the original state and is never updated after initial load.
|
||||
* Used to detect unsaved changes in edit mode by comparing with current state.
|
||||
*/
|
||||
public ?string $originalFingerprint = null;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -100,6 +100,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
|
||||
// Extract current service selections from submitted data
|
||||
$selectedServices = $this->getFieldValue($submittedData, $this->getFieldName()) ?? [];
|
||||
|
||||
// Debug: Log what was submitted
|
||||
$submittedIds = array_map(fn($s) => is_object($s) ? $s->id : $s, $selectedServices);
|
||||
error_log(sprintf('[AdditionalServices] Participant %d: Submitted service IDs: [%s]', $participantIndex, implode(', ', $submittedIds)));
|
||||
|
||||
// Get available additional services from travel data
|
||||
$availableServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL);
|
||||
|
||||
@@ -111,6 +115,10 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
|
||||
$participantIndex
|
||||
);
|
||||
|
||||
// Debug: Log what passed validation
|
||||
$validIds = array_map(fn($s) => $s->id, $validSelections);
|
||||
error_log(sprintf('[AdditionalServices] Participant %d: Valid service IDs after filtering: [%s]', $participantIndex, implode(', ', $validIds)));
|
||||
|
||||
// Update participant with validated selections
|
||||
$participant->additionalServices = $validSelections;
|
||||
}
|
||||
@@ -173,17 +181,33 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
|
||||
$service = $this->findServiceInAvailableServices($selectedService, $availableServices);
|
||||
|
||||
if (null === $service) {
|
||||
error_log(sprintf('[AdditionalServices] Participant %d: Service %s NOT FOUND in available services', $participantIndex, is_object($selectedService) ? $selectedService->id : $selectedService));
|
||||
return false; // Service not found in available services
|
||||
}
|
||||
|
||||
// Check if service has age constraints
|
||||
$ageEvaluator = new ServiceAgeEvaluator();
|
||||
if (false === $ageEvaluator->canEvaluate($service)) {
|
||||
error_log(sprintf('[AdditionalServices] Participant %d: Service %d (%s) has NO age constraints - VALID', $participantIndex, $service->id, $service->label));
|
||||
return true; // No age restrictions, service is valid
|
||||
}
|
||||
|
||||
// Validate service against participant's age
|
||||
return $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex);
|
||||
$isValid = $ageEvaluator->isServiceAvailableForParticipant($service, $bookingDto, $participantIndex);
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
$age = $participant?->getAge($bookingDto->travel->dateFrom);
|
||||
|
||||
error_log(sprintf(
|
||||
'[AdditionalServices] Participant %d (age %s): Service %d (%s) age validation = %s. Constraints: %s',
|
||||
$participantIndex,
|
||||
$age ?? 'unknown',
|
||||
$service->id,
|
||||
$service->label,
|
||||
$isValid ? 'VALID' : 'INVALID',
|
||||
$ageEvaluator->getConstraintDescription($service)
|
||||
));
|
||||
|
||||
return $isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,7 +76,7 @@ class ParticipantFieldHandlerRegistry
|
||||
* automatically cleared.
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted form data containing participants array
|
||||
* @param BookingDto $bookingDto The booking DTO to update with processed field values
|
||||
* @param BookingDto $bookingDto The booking DTO to update with processed field values
|
||||
*
|
||||
* @return array<string, mixed> The synchronized submitted data reflecting DTO changes
|
||||
*/
|
||||
@@ -108,7 +108,7 @@ class ParticipantFieldHandlerRegistry
|
||||
* family detection which needs all participants' ages to be processed first).
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The submitted form data containing participants array
|
||||
* @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit)
|
||||
* @param BookingDto $bookingDto The booking DTO to update with processed field values (create or edit)
|
||||
*/
|
||||
public function processFields(array $submittedData, BookingDto $bookingDto): void
|
||||
{
|
||||
@@ -133,7 +133,7 @@ class ParticipantFieldHandlerRegistry
|
||||
}
|
||||
|
||||
// Let each handler decide if it should process this participant's data
|
||||
if ($handler->shouldProcess($participantData, $bookingDto->mode, (int) $participantIndex)) {
|
||||
if ($handler->shouldProcess($participantData, $bookingDto->getMode(), (int) $participantIndex)) {
|
||||
$handler->processField($participantData, $bookingDto, (int) $participantIndex);
|
||||
}
|
||||
}
|
||||
@@ -149,9 +149,9 @@ class ParticipantFieldHandlerRegistry
|
||||
*
|
||||
* Handlers are executed in dependency order to ensure proper data consistency.
|
||||
*
|
||||
* @param array<string, mixed> $participantData Submitted data for one participant
|
||||
* @param BookingDto $bookingDto The booking DTO to update
|
||||
* @param int $participantIndex Index of participant to process
|
||||
* @param array<string, mixed> $participantData Submitted data for one participant
|
||||
* @param BookingDto $bookingDto The booking DTO to update
|
||||
* @param int $participantIndex Index of participant to process
|
||||
*/
|
||||
public function processFieldsForParticipant(array $participantData, BookingDto $bookingDto, int $participantIndex): void
|
||||
{
|
||||
@@ -163,7 +163,7 @@ class ParticipantFieldHandlerRegistry
|
||||
$handler = $this->handlers[$handlerName];
|
||||
|
||||
// Let each handler decide if it should process this participant's data
|
||||
if ($handler->shouldProcess($participantData, $bookingDto->mode, $participantIndex)) {
|
||||
if ($handler->shouldProcess($participantData, $bookingDto->getMode(), $participantIndex)) {
|
||||
$handler->processField($participantData, $bookingDto, $participantIndex);
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class ParticipantFieldHandlerRegistry
|
||||
* the current DTO state and updating the corresponding submitted data fields.
|
||||
*
|
||||
* @param array<string, mixed> $submittedData The original submitted form data
|
||||
* @param BookingDto $bookingDto The DTO with cleaned data from field handlers
|
||||
* @param BookingDto $bookingDto The DTO with cleaned data from field handlers
|
||||
*
|
||||
* @return array<string, mixed> Updated submitted data reflecting DTO state
|
||||
*/
|
||||
@@ -326,7 +326,7 @@ class ParticipantFieldHandlerRegistry
|
||||
* @param array<string, mixed> $participantData The submitted participant data
|
||||
* @param ParticipantDto $participant The cleaned participant DTO
|
||||
* @param int $index The participant index
|
||||
* @param BookingDto $bookingDto The booking DTO for mode detection
|
||||
* @param BookingDto $bookingDto The booking DTO for mode detection
|
||||
*
|
||||
* @return array<string, mixed> Updated participant data with synchronized field values
|
||||
*/
|
||||
|
||||
@@ -125,7 +125,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_COURSES),
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(
|
||||
Constants::TOKEN_COURSES,
|
||||
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
@@ -143,8 +146,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$attributes['data-description'] = $service->description;
|
||||
}
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'courses')) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'ausgebucht';
|
||||
}
|
||||
@@ -160,7 +163,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL),
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(
|
||||
Constants::TOKEN_ADDITIONAL,
|
||||
BookingDto::MODE_EDIT !== $bookingDto->getMode() // Only filter by availability in create mode
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
@@ -187,7 +193,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
}
|
||||
|
||||
// Make readonly if service is unavailable (only if not already mandatory)
|
||||
if (false === $service->mandatory && $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
if (false === $service->mandatory && $this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'additionalServices')) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'ausgebucht';
|
||||
}
|
||||
@@ -203,7 +209,10 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_BOARD),
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(
|
||||
Constants::TOKEN_BOARD,
|
||||
BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter by availability in create mode
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
@@ -216,8 +225,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
$attributes = [];
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'board')) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'ausgebucht';
|
||||
}
|
||||
@@ -234,7 +243,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'required' => false,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$this->filterRentalsBySkiPassDuration(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_RENTALS, true, true),
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(
|
||||
Constants::TOKEN_RENTALS,
|
||||
BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode
|
||||
true // Filter by travel date range
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
@@ -255,8 +268,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$attributes['data-description'] = $service->description;
|
||||
}
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'rentals')) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'ausgebucht';
|
||||
}
|
||||
@@ -292,7 +305,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'expanded' => true,
|
||||
'required' => true,
|
||||
'choices' => $this->filterServicesByAgeConstraints(
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_SKI_PASS, true, true),
|
||||
$bookingDto->travel->getAdditionalServicesBySubTypes(
|
||||
Constants::TOKEN_SKI_PASS,
|
||||
BookingDto::MODE_CREATE === $bookingDto->getMode(), // Only filter by availability in create mode
|
||||
true // Filter by travel date range
|
||||
),
|
||||
$bookingDto,
|
||||
$participantIndex
|
||||
),
|
||||
@@ -310,8 +327,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$attributes['data-description'] = $service->description;
|
||||
}
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// Make readonly if service is unavailable (intelligently handles edit mode)
|
||||
if ($this->shouldMakeServiceReadonly($service, $bookingDto, $participantIndex, 'skiPass')) {
|
||||
$attributes['readonly'] = true;
|
||||
$attributes['data-tooltip'] = 'ausgebucht';
|
||||
}
|
||||
@@ -348,19 +365,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
$attributes = [];
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// 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;
|
||||
},
|
||||
'attr' => [
|
||||
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
|
||||
'hx-swap' => 'none',
|
||||
'hx-trigger' => 'change',
|
||||
],
|
||||
];
|
||||
|
||||
// Inbound Transportation
|
||||
@@ -379,19 +391,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
|
||||
$attributes = [];
|
||||
|
||||
// Make readonly if service is unavailable
|
||||
if ($this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex)) {
|
||||
// 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;
|
||||
},
|
||||
'attr' => [
|
||||
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
|
||||
'hx-swap' => 'none',
|
||||
'hx-trigger' => 'change',
|
||||
],
|
||||
];
|
||||
|
||||
// Pickup (conditional - only shown when either transportation direction is bus)
|
||||
@@ -419,11 +426,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
$this->fieldOptionProviders['bulkInsuranceBooking'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||
'label' => 'Für alle Teilnehmer buchen',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
|
||||
'hx-swap' => 'none',
|
||||
'hx-trigger' => 'change',
|
||||
],
|
||||
];
|
||||
|
||||
// Insurance field provider - provides age and eligibility filtered insurances for participants
|
||||
@@ -434,11 +436,6 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
'insurances' => $this->getEligibleInsurances($bookingDto, $participantIndex),
|
||||
'attr' => [
|
||||
'hx-post' => $this->urlGenerator->generate('app_booking_create_step_2_refresh'),
|
||||
'hx-swap' => 'none',
|
||||
'hx-trigger' => 'change',
|
||||
],
|
||||
];
|
||||
|
||||
// Future field providers would be added here, for example:
|
||||
@@ -601,6 +598,78 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
return $this->serviceAvailabilityCalculator->isServiceUnavailable($service->id, $bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a service should be rendered as read-only.
|
||||
*
|
||||
* This method intelligently handles readonly state for services in both create and edit modes:
|
||||
*
|
||||
* - CREATE MODE: Uses existing availability calculator logic
|
||||
* - EDIT MODE: Services unavailable (available <= 0) are readonly ONLY if participant doesn't already have them
|
||||
*
|
||||
* This prevents fingerprint false positives in edit mode by allowing participants to keep
|
||||
* services they already have, even if those services are now fully booked.
|
||||
*
|
||||
* @param Service $service The service to check
|
||||
* @param BookingDto $bookingDto The booking DTO containing participant data
|
||||
* @param int $participantIndex Index of the participant currently selecting services
|
||||
* @param string $fieldName Name of the service field (e.g., 'courses', 'board', 'rentals')
|
||||
*
|
||||
* @return bool True if the service should be read-only
|
||||
*/
|
||||
private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool
|
||||
{
|
||||
// In CREATE mode, use existing availability logic
|
||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||
return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex);
|
||||
}
|
||||
|
||||
// In EDIT mode, apply intelligent readonly logic
|
||||
// If service is available (available > 0), it's never readonly
|
||||
if (null !== $service->available && $service->available > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Service is unavailable - check if participant already has it
|
||||
$participant = $bookingDto->getParticipant($participantIndex);
|
||||
if (null === $participant) {
|
||||
return true; // Readonly if no participant data
|
||||
}
|
||||
|
||||
// Check if participant has this service based on field type
|
||||
$participantHasService = match ($fieldName) {
|
||||
'courses' => $this->hasServiceById($participant->courses, $service->id),
|
||||
'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id),
|
||||
'board' => $this->hasServiceById($participant->board, $service->id),
|
||||
'rentals' => $this->hasServiceById($participant->rentals, $service->id),
|
||||
'skiPass' => $participant->skiPass?->id === $service->id,
|
||||
'transportationOutbound' => $participant->transportationOutbound?->id === $service->id,
|
||||
'transportationInbound' => $participant->transportationInbound?->id === $service->id,
|
||||
default => false,
|
||||
};
|
||||
|
||||
// Make readonly only if participant doesn't have it
|
||||
return false === $participantHasService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a service array contains a service with the given ID.
|
||||
*
|
||||
* @param array $services Array of Service objects
|
||||
* @param int $serviceId Service ID to search for
|
||||
*
|
||||
* @return bool True if the service is found in the array
|
||||
*/
|
||||
private function hasServiceById(array $services, int $serviceId): bool
|
||||
{
|
||||
foreach ($services as $service) {
|
||||
if ($service->id === $serviceId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters services based on participant's age constraints.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user