wip: show pricing

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 6bd483d768
commit bd5c6359fe
19 changed files with 602 additions and 183 deletions
+92 -107
View File
@@ -30,54 +30,18 @@ class BookingCreateParticipantType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'label' => 'Vorname',
'clean_xss' => true,
])
->add('lastName', TextType::class, [
'label' => 'Nachname',
'clean_xss' => true,
])
->add('dateOfBirth', BirthdayType::class, [
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
])
->add('gender', ChoiceType::class, [
'label' => 'Geschlecht',
'required' => false,
'placeholder' => 'keine Angabe',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
])
->add('nationality', CountryType::class, [
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
'required' => false,
'clean_xss' => true,
])
->add('mobile', TextType::class, [
'label' => 'Telefon (mobil)',
'required' => false,
'clean_xss' => true,
])
->add('bodyDimensions', BodyDimensionsType::class)
->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData'])
->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit']);
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$this->onPreSetData($event);
})
->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$this->onPreSubmit($event);
});
}
/**
* Adds dynamic fields to the form based on participant data.
*/
public function onPreSetData(FormEvent $event): void
private function onPreSetData(FormEvent $event): void
{
/** @var ParticipantDto|null $participantData */
$participantData = $event->getData();
@@ -94,14 +58,17 @@ class BookingCreateParticipantType extends AbstractType
return;
}
// Add base fields with states applied
$this->addBaseFields($form, $bookingDto, $participantData->index);
// Add dynamic fields
$this->addDynamicFields($form, $bookingDto, $participantData->index);
$this->applyFieldStates($form, $bookingDto, $participantData->index);
}
/**
* Handles form pre-submit events to update field states based on submitted data.
*/
public function onPreSubmit(FormEvent $event): void
private function onPreSubmit(FormEvent $event): void
{
$submittedData = $event->getData();
$form = $event->getForm();
@@ -123,59 +90,71 @@ class BookingCreateParticipantType extends AbstractType
return;
}
// Apply updated field states based on submitted data
$this->applyFieldStates($form, $bookingDto, $participantData->index, $submittedData);
// Rebuild all fields with updated states based on submitted data
$this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData);
}
/**
* Applies dynamic field states to all form fields.
*
* This method checks each form field for state conditions and applies
* the appropriate state modifications (readonly, disabled, etc.).
* Fields that should be hidden are removed from the form entirely.
*
* @param FormInterface $form The form to modify
* @param BookingDtoInterface $bookingDto The booking data for context (create or edit)
* @param int $participantIndex The participant index
* @param array<string, mixed> $formData Optional submitted form data for state calculation
* Adds base fields to the form with field states applied.
*/
private function applyFieldStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $formData = []): void
private function addBaseFields(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex): void
{
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $formData);
// Get field states for base fields
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex);
// Get all fields that have state conditions (excluding hidden fields)
$allFieldStates = $this->fieldStateProvider->getAllFieldStates($bookingDto, $participantIndex, $formData);
// Helper to get field state or empty array
$getFieldState = fn (string $fieldName) => $allFieldStates[$fieldName] ?? [];
// Handle body dimension fields separately as they are nested in bodyDimensions form
$bodyDimensionFields = ['height', 'weight', 'shoeSize'];
$bodyDimensionStates = [];
$form
->add('firstName', TextType::class, $this->mergeFieldState([
'label' => 'Vorname',
'clean_xss' => true,
], $getFieldState('firstName')))
->add('lastName', TextType::class, $this->mergeFieldState([
'label' => 'Nachname',
'clean_xss' => true,
], $getFieldState('lastName')))
->add('dateOfBirth', BirthdayType::class, $this->mergeFieldState([
'label' => 'Geburtsdatum',
'html5' => true,
'widget' => 'single_text',
'input' => 'datetime_immutable',
], $getFieldState('dateOfBirth')))
->add('gender', ChoiceType::class, $this->mergeFieldState([
'label' => 'Geschlecht',
'required' => false,
'placeholder' => 'keine Angabe',
'choices' => [
'männlich' => 'M',
'weiblich' => 'W',
'divers' => 'D',
],
], $getFieldState('gender')))
->add('nationality', CountryType::class, $this->mergeFieldState([
'label' => 'Nationalität',
'property' => 'nationality',
'preferred_choices' => ['D', 'A', 'CH'],
], $getFieldState('nationality')))
->add('email', EmailType::class, $this->mergeFieldState([
'label' => 'E-Mail',
'required' => false,
'clean_xss' => true,
], $getFieldState('email')))
->add('mobile', TextType::class, $this->mergeFieldState([
'label' => 'Telefon (mobil)',
'required' => false,
'clean_xss' => true,
], $getFieldState('mobile')));
foreach ($allFieldStates as $fieldName => $fieldState) {
if (in_array($fieldName, $bodyDimensionFields, true)) {
// Collect body dimension field states for later processing
$bodyDimensionStates[$fieldName] = $fieldState;
continue;
}
if ($form->has($fieldName)) {
$field = $form->get($fieldName);
$currentOptions = $field->getConfig()->getOptions();
// Merge state into current options
$updatedOptions = $this->mergeFieldState($currentOptions, $fieldState);
// Remove and re-add the field with updated options
$fieldType = $field->getConfig()->getType()->getInnerType();
$form->remove($fieldName);
$form->add($fieldName, $fieldType::class, $updatedOptions);
// Add body dimensions with state handling
$bodyDimensionStates = ['height' => $getFieldState('height'), 'weight' => $getFieldState('weight'), 'shoeSize' => $getFieldState('shoeSize')];
$bodyDimensionOptions = [];
foreach ($bodyDimensionStates as $fieldName => $fieldState) {
if (isset($fieldState['required']) && true === $fieldState['required']) {
$bodyDimensionOptions[$fieldName.'_required'] = true;
}
}
// Apply body dimension field states to the nested bodyDimensions form
if (!empty($bodyDimensionStates) && $form->has('bodyDimensions')) {
$this->applyBodyDimensionStates($form, $bodyDimensionStates);
}
$form->add('bodyDimensions', BodyDimensionsType::class, $bodyDimensionOptions);
}
/**
@@ -209,30 +188,36 @@ class BookingCreateParticipantType extends AbstractType
}
/**
* Applies field states to body dimension fields in the nested bodyDimensions form.
*
* This method handles the special case of body dimension fields which are embedded
* in a nested form type. It converts field state requirements into options that
* can be passed to the BodyDimensionsType.
*
* @param FormInterface $form The parent form containing bodyDimensions
* @param array<string, array<string, mixed>> $bodyDimensionStates Field states for body dimension fields
* Rebuilds all fields with updated states based on submitted data.
*/
private function applyBodyDimensionStates(FormInterface $form, array $bodyDimensionStates): void
private function rebuildFieldsWithStates(FormInterface $form, BookingDtoInterface $bookingDto, int $participantIndex, array $submittedData): void
{
$bodyDimensionsField = $form->get('bodyDimensions');
$currentOptions = $bodyDimensionsField->getConfig()->getOptions();
// First, remove fields that should be excluded entirely
$this->removeExcludedFields($form, $bookingDto, $participantIndex, $submittedData);
// Convert field states to BodyDimensionsType options
foreach ($bodyDimensionStates as $fieldName => $fieldState) {
if (isset($fieldState['required']) && true === $fieldState['required']) {
$currentOptions[$fieldName.'_required'] = true;
// Clear the form and rebuild from scratch with updated states
// Rebuild base fields with updated states
$baseFields = ['firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile'];
foreach ($baseFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
}
}
// Remove and re-add the bodyDimensions field with updated options
$form->remove('bodyDimensions');
$form->add('bodyDimensions', BodyDimensionsType::class, $currentOptions);
// Re-add base fields with updated states
$this->addBaseFields($form, $bookingDto, $participantIndex);
// Rebuild dynamic fields
$dynamicFields = ['assignedRoomId', 'remarksRoom', 'courses', 'additionalServices', 'board', 'rentals', 'skiPass'];
foreach ($dynamicFields as $fieldName) {
if ($form->has($fieldName)) {
$form->remove($fieldName);
}
}
// Re-add dynamic fields
$this->addDynamicFields($form, $bookingDto, $participantIndex);
}
/**
@@ -261,7 +246,7 @@ class BookingCreateParticipantType extends AbstractType
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
// Skip choice fields without any choices
if ($fieldType === ChoiceType::class && false === $this->hasValidFieldOptions($fieldOptions)) {
if (ChoiceType::class === $fieldType && false === $this->hasValidFieldOptions($fieldOptions)) {
continue;
}
+1
View File
@@ -14,6 +14,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateStep2Type extends AbstractType
{
public function __construct(
private readonly ParticipantFieldHandlerRegistry $participantFieldHandlerRegistry,
) {
+18 -9
View File
@@ -20,9 +20,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingEditParticipantType extends AbstractType
{
public function __construct(
private readonly EditFieldStateProvider $fieldStateProvider,
) {
public function __construct(private readonly EditFieldStateProvider $fieldStateProvider)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -299,14 +298,24 @@ class BookingEditParticipantType extends AbstractType
$personalDataFields = [
'firstName', 'lastName', 'dateOfBirth', 'gender', 'nationality', 'email', 'mobile',
];
// Re-apply field states by rebuilding fields with updated state
// Note: We need to store the field types and rebuild them since we cannot modify existing field configs
$fieldDefinitions = [
'firstName' => [TextType::class, ['label' => 'Vorname', 'clean_xss' => true]],
'lastName' => [TextType::class, ['label' => 'Nachname', 'clean_xss' => true]],
'dateOfBirth' => [BirthdayType::class, ['label' => 'Geburtsdatum', 'html5' => true, 'widget' => 'single_text', 'input' => 'datetime_immutable']],
'gender' => [ChoiceType::class, ['label' => 'Geschlecht', 'required' => false, 'placeholder' => 'keine Angabe', 'choices' => ['männlich' => 'M', 'weiblich' => 'W', 'divers' => 'D']]],
'nationality' => [CountryType::class, ['label' => 'Nationalität', 'property' => 'nationality', 'preferred_choices' => ['D', 'A', 'CH']]],
'email' => [EmailType::class, ['label' => 'E-Mail', 'required' => false, 'clean_xss' => true]],
'mobile' => [TextType::class, ['label' => 'Telefon (mobil)', 'required' => false, 'clean_xss' => true]],
];
foreach ($personalDataFields as $field) {
if ($form->has($field)) {
$fieldConfig = $form->get($field)->getConfig();
$currentOptions = $fieldConfig->getOptions();
$updatedOptions = $this->mergeFieldState($currentOptions, $getState($field));
$fieldType = $fieldConfig->getType()->getInnerType();
if ($form->has($field) && isset($fieldDefinitions[$field])) {
[$fieldType, $baseOptions] = $fieldDefinitions[$field];
$updatedOptions = $this->mergeFieldState($baseOptions, $getState($field));
$form->remove($field);
$form->add($field, $fieldType::class, $updatedOptions);
$form->add($field, $fieldType, $updatedOptions);
}
}
+10 -2
View File
@@ -17,12 +17,19 @@ class RoomSelectType extends AbstractType
{
$builder
->add('roomId', HiddenType::class)
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options) {
$data = $event->getData();
$form = $event->getForm();
// Build label with pricing
$label = 'Anzahl ' . $data->roomLabel;
if (null !== $options['room_price']) {
$formattedPrice = number_format((float) $options['room_price'], 2, ',', '.');
$label .= sprintf(' (€%s pro Nacht)', $formattedPrice);
}
$form->add('quantity', ChoiceType::class, [
'label' => 'Anzahl '.$data->roomLabel,
'label' => $label,
'required' => true,
'choices' => ['-' => 0] + array_combine(range(1, $data->maxQuantity), range(1, $data->maxQuantity)),
]);
@@ -33,6 +40,7 @@ class RoomSelectType extends AbstractType
{
$resolver->setDefaults([
'data_class' => RoomSelectionDto::class,
'room_price' => null,
]);
}
}
@@ -39,10 +39,11 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array
{
// Check if we have a provider for this field
if (false === isset($this->fieldOptionProviders[$fieldName])) {
@@ -50,7 +51,7 @@ abstract class AbstractFieldOptionsProvider implements FieldOptionsProviderInter
}
// Execute the provider with current context to generate dynamic options
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex);
return $this->fieldOptionProviders[$fieldName]($bookingDto, $participantIndex, $options);
}
/**
@@ -44,10 +44,11 @@ interface FieldOptionsProviderInterface
* @param string $fieldName The name of the field to configure
* @param BookingDtoInterface $bookingDto The current booking data for context (create or edit)
* @param int $participantIndex The index of the participant being configured
* @param array $options Additional options to customize field behavior
*
* @return array<string, mixed> Symfony form field options, or empty array if field not supported
*/
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex): array;
public function getFieldOptions(string $fieldName, BookingDtoInterface $bookingDto, int $participantIndex, array $options = []): array;
/**
* Checks whether a field has option provider support.
@@ -137,7 +137,11 @@ class ParticipantAdditionalServicesFieldHandler extends AbstractParticipantField
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -79,7 +79,11 @@ class ParticipantBoardFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -126,7 +126,11 @@ class ParticipantCoursesFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
/**
@@ -17,7 +18,7 @@ use App\Form\Service\Contract\ParticipantFieldHandlerInterface;
*/
class ParticipantFieldHandlerRegistry
{
/** @var ParticipantFieldHandlerInterface[] Registered handlers indexed by field name */
/** @var array<string, ParticipantFieldHandlerInterface> Registered handlers indexed by field name */
private array $handlers = [];
/** @var string[]|null Cached array of handler names sorted by dependency order */
@@ -274,11 +275,11 @@ class ParticipantFieldHandlerRegistry
* been processed by checking against registered handlers.
*
* @param array<string, mixed> $participantData The submitted participant data
* @param object $participant The cleaned participant DTO
* @param ParticipantDto $participant The cleaned participant DTO
*
* @return array<string, mixed> Updated participant data with synchronized field values
*/
private function syncParticipantData(array $participantData, object $participant): array
private function syncParticipantData(array $participantData, ParticipantDto $participant): array
{
// Sync fields for all registered handlers
foreach ($this->handlers as $fieldName => $handler) {
@@ -69,7 +69,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
protected function registerFieldOptionProviders(): void
{
// Room assignment field provider (only available for create workflow)
$this->fieldOptionProviders['assignedRoomId'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$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:
@@ -86,7 +86,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Courses field provider - provides age-appropriate courses from travel data
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['courses'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Kurse',
'multiple' => true,
'expanded' => true,
@@ -97,11 +97,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Additional services field provider - provides age-appropriate additional services with mandatory pre-selection
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['additionalServices'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Zusatzleistungen',
'multiple' => true,
'expanded' => true,
@@ -112,7 +112,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
'choice_attr' => function (?Service $service) {
if (null === $service) {
return [];
@@ -133,7 +133,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
// Board field provider - provides age-appropriate board options from travel data
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['board'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Verpflegung',
'multiple' => true,
'expanded' => true,
@@ -144,11 +144,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Rentals field provider - provides age-appropriate rental options from travel data filtered by date range
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['rentals'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Leihmaterial',
'multiple' => true,
'expanded' => true,
@@ -159,11 +159,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Skipass field provider - provides age-appropriate skipass options from travel data filtered by date range
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['skiPass'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Skipass',
'multiple' => false,
'expanded' => true,
@@ -174,11 +174,11 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$participantIndex
),
'choice_value' => 'id',
'choice_label' => 'label',
'choice_label' => fn (?Service $service) => $this->formatServiceLabelWithPrice($service),
];
// Room remarks field provider - provides textarea for room-specific remarks (only for 'mbz' rooms)
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex) => [
$this->fieldOptionProviders['remarksRoom'] = fn (BookingDtoInterface $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Wünsche oder Anmerkungen zum Zimmer',
'required' => false,
'clean_xss' => true,
@@ -199,6 +199,26 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// ];
}
/**
* 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;
}
return sprintf('%s (€%s)', $service->label, number_format($service->price, 2, ',', '.'));
}
/**
* Filters services based on participant's age constraints.
*
@@ -79,7 +79,11 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler
foreach ($selectedServices as $selectedService) {
if ($this->isServiceValidForParticipant($selectedService, $availableServices, $bookingDto, $participantIndex)) {
$validSelections[] = $selectedService;
// Convert the selected service ID/data back to the actual Service object
$serviceObject = $this->findServiceInAvailableServices($selectedService, $availableServices);
if (null !== $serviceObject) {
$validSelections[] = $serviceObject;
}
}
}