feat: refactor to cards

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 32a9fac9ed
commit e51c4843c5
40 changed files with 2639 additions and 3097 deletions
@@ -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.
*