feat: show room prices in room assignment form field

This commit is contained in:
Björn Fromme
2026-03-16 12:00:55 +01:00
parent 5b75a165ce
commit e1bfe9b93c
22 changed files with 193 additions and 92 deletions
@@ -152,9 +152,9 @@ class BookingDataProcessor
$room = $availableRooms[$bookingRoom->id] ?? null;
if (null !== $room && null !== $bookingRoom->totalCount && $bookingRoom->totalCount > 0) {
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = $bookingRoom->id;
$roomSelection->roomLabel = $room->label;
$roomSelection->roomPrice = $room->price;
$roomSelection->id = $bookingRoom->id;
$roomSelection->label = $room->label;
$roomSelection->price = $room->price;
$roomSelection->quantity = $bookingRoom->totalCount; // Actual quantity from booking XML (anzahl)
$dto->roomSelections[] = $roomSelection;
}
@@ -451,7 +451,7 @@ class BookingPayloadBuilder
$roomQuantities = [];
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->quantity > 0) {
$roomQuantities[$selection->roomId] = $selection->quantity;
$roomQuantities[$selection->id] = $selection->quantity;
}
}
+3 -2
View File
@@ -316,7 +316,7 @@ class BookingParticipantType extends AbstractType
private function addDynamicFields(FormInterface $form, BookingDto $bookingDto, int $participantIndex): void
{
$dynamicFields = [
'assignedRoomId' => ChoiceType::class,
'assignedRoomId' => RoomAssignmentType::class,
'remarksRoom' => TextareaType::class,
'courses' => ChoiceType::class,
'additionalServices' => ChoiceType::class,
@@ -350,7 +350,8 @@ class BookingParticipantType extends AbstractType
$fieldOptions = $this->fieldOptionsProvider->getFieldOptions($fieldName, $bookingDto, $participantIndex);
// Skip choice fields without any choices
if (ChoiceType::class === $fieldType && false === $this->hasValidFieldOptions($fieldOptions)) {
$isChoiceField = ChoiceType::class === $fieldType || RoomAssignmentType::class === $fieldType;
if ($isChoiceField && false === $this->hasValidFieldOptions($fieldOptions)) {
continue;
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Form\DataTransformer;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;
/**
* Transforms between RoomSelectionDto objects and integer room IDs.
*
* This transformer enables using RoomSelectionDto objects as choices while
* binding integer IDs to the model. This allows the template to access
* the full object (including price) via choiceData while the form binds
* the scalar ID to the participant's assignedRoomId property.
*
* @implements DataTransformerInterface<int|null, RoomSelectionDto|null>
*/
class RoomSelectionToIdTransformer implements DataTransformerInterface
{
/**
* @param RoomSelectionDto[] $roomSelections Available room selections for reverse lookup
*/
public function __construct(
private readonly array $roomSelections,
) {
}
/**
* Transforms an integer room ID to a RoomSelectionDto for form display.
*
* @param int|null $value The room ID from the model
*
* @return RoomSelectionDto|null The matching RoomSelectionDto or null
*/
public function transform(mixed $value): ?RoomSelectionDto
{
if (null === $value) {
return null;
}
foreach ($this->roomSelections as $roomSelection) {
if ($roomSelection->id === $value) {
return $roomSelection;
}
}
return null;
}
/**
* Transforms a RoomSelectionDto back to an integer room ID for the model.
*
* @param RoomSelectionDto|null $value The selected RoomSelectionDto from the form
*
* @return int|null The room ID or null
*
* @throws TransformationFailedException If an unexpected value type is received
*/
public function reverseTransform(mixed $value): ?int
{
if (null === $value) {
return null;
}
if ($value instanceof RoomSelectionDto) {
return $value->id;
}
// Handle case where form submits scalar ID directly
if (is_int($value) || is_string($value)) {
return (int) $value;
}
throw new TransformationFailedException(sprintf('Expected RoomSelectionDto, int, or null, got %s', get_debug_type($value)));
}
}
+1 -1
View File
@@ -292,7 +292,7 @@ class BookingDto
public function createRoomSelectionSnapshot(): array
{
return array_map(
fn ($roomSelection) => [(int) $roomSelection->roomId, (int) $roomSelection->quantity],
fn ($roomSelection) => [(int) $roomSelection->id, (int) $roomSelection->quantity],
$this->roomSelections
);
}
+5 -3
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use Symfony\Component\Validator\Constraints as Assert;
@@ -7,11 +9,11 @@ use Symfony\Component\Validator\Constraints as Assert;
class RoomSelectionDto
{
#[Assert\NotNull(message: 'Bitte eine Zimmerkategorie auswählen', groups: ['booking_create_step_1'])]
public ?int $roomId = null;
public ?int $id = null;
public ?string $roomLabel = null;
public ?string $label = null;
public ?float $roomPrice = null;
public ?float $price = null;
public ?int $quantity = null;
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\DataTransformer\RoomSelectionToIdTransformer;
use App\Form\Model\RoomSelectionDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type for room assignment that uses RoomSelectionDto objects as choices
* while binding integer IDs to the model.
*
* This type wraps ChoiceType and adds a model transformer to convert between
* RoomSelectionDto objects (for template access to price data) and integer IDs
* (for the participant's assignedRoomId property).
*/
class RoomAssignmentType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addModelTransformer(
new RoomSelectionToIdTransformer($options['choices'])
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'expanded' => true,
'multiple' => false,
'choice_value' => fn (RoomSelectionDto|int|null $room) => $room instanceof RoomSelectionDto ? $room->id : $room,
'choice_label' => fn (?RoomSelectionDto $room) => $room?->label,
]);
}
public function getParent(): string
{
return ChoiceType::class;
}
}
+4 -4
View File
@@ -17,7 +17,7 @@ class RoomSelectType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('roomId', HiddenType::class)
->add('id', HiddenType::class)
->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
@@ -36,11 +36,11 @@ class RoomSelectType extends AbstractType
{
$data = $form->getData();
$view->vars['label_room'] = $data->roomLabel;
$view->vars['label_room'] = $data->label;
$view->vars['label_price'] = null;
if (null !== $data->roomPrice) {
$formattedPrice = number_format((float) $data->roomPrice, 2, ',', '.');
if (null !== $data->price) {
$formattedPrice = number_format((float) $data->price, 2, ',', '.');
$view->vars['label_price'] = sprintf(' %s € pro Person', $formattedPrice);
}
}
@@ -17,6 +17,7 @@ use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\PersonalDataMutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\RoomSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SkiPassSelectionCondition;
use App\Form\Service\Condition\TransportationServicesMutabilityCondition;
@@ -104,6 +105,12 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
'static_text' => new BookingModeCondition(BookingDto::MODE_EDIT),
];
// Show remarks room field only when room with code 'mbz' (single bed) is selected
$sharedRoomCondition = new RoomSelectionCondition(['mbz']);
$this->fieldStateConditions['remarksRoom'] = [
'hidden' => CompositeCondition::not($sharedRoomCondition),
];
// Conditional visibility for service fields (same as create flow)
$rentalCondition = new RentalSelectionCondition();
$skiPassCondition = new SkiPassSelectionCondition();
@@ -110,7 +110,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
// Get the room selection details
$roomSelection = null;
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->roomId === $roomId) {
if ($selection->id === $roomId) {
$roomSelection = $selection;
break;
}
@@ -164,7 +164,7 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
// Calculate how many need to be unassigned
$roomSelection = null;
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->roomId === $roomId) {
if ($selection->id === $roomId) {
$roomSelection = $selection;
break;
}
@@ -229,8 +229,8 @@ class ParticipantAssignedRoomFieldHandler extends AbstractParticipantFieldHandle
private function getRoomLabelById(int $roomId, BookingDto $bookingDto): string
{
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->roomId === $roomId) {
return $selection->roomLabel;
if ($selection->id === $roomId) {
return $selection->label;
}
}
@@ -73,15 +73,14 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
// Create context: use selected rooms from step 1, filtered by participant age
$filteredRooms = $this->filterRoomsByParticipantAge(
$choices = $this->filterRoomsByParticipantAge(
$bookingDto->getSelectedRooms(),
$bookingDto,
$participantIndex
);
$choices = $this->buildRoomChoicesFromSelections($filteredRooms);
} elseif (BookingDto::MODE_EDIT === $bookingDto->getMode()) {
// Edit context: use already-booked rooms from booking
$choices = $this->buildRoomChoicesFromBookedRooms($bookingDto->booking->rooms);
// Edit context: convert booked rooms to RoomSelectionDto for consistent handling
$choices = $this->convertBookedRoomsToSelectionDtos($bookingDto->booking->rooms);
}
$singleChoice = 1 === count($choices);
@@ -93,6 +92,8 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
// No placeholder when only one room available - the only option is pre-selected
'placeholder' => $singleChoice ? false : 'Nicht zugeordnet',
'choices' => $choices,
'choice_value' => fn (RoomSelectionDto|int|null $room) => $room instanceof RoomSelectionDto ? $room->id : $room,
'choice_label' => fn (?RoomSelectionDto $room) => $room?->label,
];
};
@@ -511,7 +512,7 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
$availableRooms = $bookingDto->travel->getAvailableRooms();
return array_filter($roomSelections, function (RoomSelectionDto $roomSelection) use ($availableRooms, $age) {
$room = $availableRooms[$roomSelection->roomId] ?? null;
$room = $availableRooms[$roomSelection->id] ?? null;
// Exclude rooms not found in travel data to avoid validation issues
if (null === $room) {
@@ -529,37 +530,28 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
}
/**
* Builds room choices array from RoomSelectionDto objects.
* Converts booked Room objects to RoomSelectionDto for consistent form handling.
*
* @param RoomSelectionDto[] $roomSelections Room selections from step 1
*
* @return array<string, int> Choices array with label => id
*/
private function buildRoomChoicesFromSelections(array $roomSelections): array
{
$choices = [];
foreach ($roomSelections as $roomSelection) {
$choices[$roomSelection->roomLabel] = $roomSelection->roomId;
}
return $choices;
}
/**
* Builds room choices array from booked Room objects.
* In edit mode, we need to convert Room entities to RoomSelectionDto objects
* so the form can use the same choice_value and choice_label configuration
* as create mode, enabling consistent price display in the template.
*
* @param Room[] $bookedRooms Rooms from existing booking
*
* @return array<string, int> Choices array with label => id
* @return RoomSelectionDto[] Array of room selection DTOs
*/
private function buildRoomChoicesFromBookedRooms(array $bookedRooms): array
private function convertBookedRoomsToSelectionDtos(array $bookedRooms): array
{
$choices = [];
$selections = [];
foreach ($bookedRooms as $room) {
$choices[$room->label] = $room->id;
$selection = new RoomSelectionDto();
$selection->id = $room->id;
$selection->label = $room->label;
$selection->price = $room->price;
$selections[] = $selection;
}
return $choices;
return $selections;
}
/**
+6 -6
View File
@@ -255,9 +255,9 @@ class BookingService
private function createRoomSelection(Room $room, array $roomsIdsAndQuantities): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->roomId = $room->id;
$selection->roomLabel = $room->label;
$selection->roomPrice = $room->price;
$selection->id = $room->id;
$selection->label = $room->label;
$selection->price = $room->price;
$selection->maxQuantity = $room->available;
$selection->capacity = $room->minPax;
$selection->quantity = $roomsIdsAndQuantities[$room->id] ?? 0;
@@ -282,7 +282,7 @@ class BookingService
$rooms = $travelData->getAvailableRooms();
foreach ($roomSelections as $roomSelection) {
$room = $rooms[$roomSelection->roomId];
$room = $rooms[$roomSelection->id];
$participantsCount += $room->minPax * $roomSelection->quantity;
}
@@ -369,7 +369,7 @@ class BookingService
Room::SELECTION_TYPE_BY_ROOM => [],
];
foreach ($roomSelections as $roomSelection) {
$room = $roomsById[$roomSelection->roomId] ?? null;
$room = $roomsById[$roomSelection->id] ?? null;
if ($room) {
$type = $room->getSelectionType();
$groups[$type][] = $roomSelection;
@@ -535,7 +535,7 @@ class BookingService
// Check selected rooms for inquiry-only status
foreach ($bookingDto->getSelectedRooms() as $roomSelection) {
$room = $bookingDto->travel->getRoomById($roomSelection->roomId);
$room = $bookingDto->travel->getRoomById($roomSelection->id);
if ($room
&& Constants::STATUS_ON_REQUEST === $room->status
&& $room->available > 0) {
+2 -2
View File
@@ -118,8 +118,8 @@ class BookingSummaryDataService
$availableRooms = $bookingDto->travel->getAvailableRooms();
foreach ($bookingDto->roomSelections as $selection) {
if ($selection->quantity > 0 && isset($availableRooms[$selection->roomId])) {
$room = $availableRooms[$selection->roomId];
if ($selection->quantity > 0 && isset($availableRooms[$selection->id])) {
$room = $availableRooms[$selection->id];
$totalCapacity += $selection->quantity * ($room->maxPax ?? 0);
}
}
+1 -1
View File
@@ -66,7 +66,7 @@ class RoomAssignmentService
$availableRooms = $dto->travel->getAvailableRooms();
foreach ($selectedRooms as $roomSelection) {
$room = $availableRooms[$roomSelection->roomId] ?? null;
$room = $availableRooms[$roomSelection->id] ?? null;
if (null === $room) {
continue; // Skip if room not found
+1 -1
View File
@@ -78,7 +78,7 @@ class RoomPricingCalculator
}
foreach ($selectedRooms as $roomSelection) {
$room = $this->getRoomById($bookingDto, $roomSelection->roomId);
$room = $this->getRoomById($bookingDto, $roomSelection->id);
if (null === $room || null === $room->price) {
continue;
}
@@ -45,7 +45,7 @@ class RoomSelectionValidator extends ConstraintValidator
$hasBabyRoom = false;
foreach ($selectedRooms as $roomSelection) {
$room = $availableRooms[$roomSelection->roomId] ?? null;
$room = $availableRooms[$roomSelection->id] ?? null;
if (null === $room) {
continue;
-25
View File
@@ -84,28 +84,3 @@
{% endfor -%}
</table>
{%- endblock choice_widget_expanded -%}
{# Room assignment field - simple scalar choices (label => id), no price column #}
{%- block _participant_assignedRoomId_widget -%}
<table class="w-full table-fixed border-collapse">
{%- for child in form %}
{%- set child_attr = {} -%}
{%- if attr['hx-trigger'] is defined -%}
{%- set child_attr = {
'hx-trigger': attr['hx-trigger'],
'hx-post': attr['hx-post'],
'hx-target': attr['hx-target']|default('#main-content'),
'hx-swap': attr['hx-swap']
} -%}
{%- endif -%}
<tr>
<td class="border border-primary-bg p-2 align-top" colspan="2">
<div>{{- child.vars.label -}}</div>
</td>
<td class="border border-primary-bg p-2 align-top w-12 text-center">
{{- form_widget(child, { 'attr': child_attr }) -}}
</td>
</tr>
{% endfor -%}
</table>
{%- endblock _participant_assignedRoomId_widget -%}
@@ -130,7 +130,7 @@ class SingleRoomTypeConditionTest extends TestCase
foreach ($rooms as $roomData) {
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = $roomData['roomId'];
$roomSelection->id = $roomData['roomId'];
$roomSelection->quantity = $roomData['quantity'];
$bookingDto->roomSelections[] = $roomSelection;
}
@@ -331,8 +331,8 @@ class ParticipantAssignedRoomFieldHandlerTest extends TestCase
private function createRoomSelection(int $roomId, string $label, int $capacity, int $quantity): RoomSelectionDto
{
$selection = new RoomSelectionDto();
$selection->roomId = $roomId;
$selection->roomLabel = $label;
$selection->id = $roomId;
$selection->label = $label;
$selection->capacity = $capacity;
$selection->quantity = $quantity;
@@ -62,7 +62,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$travel->rooms = [$room];
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = 1;
$roomSelection->id = 1;
$roomSelection->quantity = 2; // 2 rooms selected
$bookingDto = new BookingDto($travel, 1);
@@ -94,7 +94,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$travel->rooms = [$room];
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = 2;
$roomSelection->id = 2;
$roomSelection->quantity = 1; // 1 room selected
$bookingDto = new BookingDto($travel, 1);
@@ -132,11 +132,11 @@ class BookingPriceCalculatorServiceTest extends TestCase
$travel->rooms = [$singleRoom, $doubleRoom];
$singleRoomSelection = new RoomSelectionDto();
$singleRoomSelection->roomId = 1;
$singleRoomSelection->id = 1;
$singleRoomSelection->quantity = 1; // 1 single room
$doubleRoomSelection = new RoomSelectionDto();
$doubleRoomSelection->roomId = 2;
$doubleRoomSelection->id = 2;
$doubleRoomSelection->quantity = 2; // 2 double rooms
$bookingDto = new BookingDto($travel, 1);
@@ -179,7 +179,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$travel->rooms = [$room];
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = 1;
$roomSelection->id = 1;
$roomSelection->quantity = 1;
$bookingDto = new BookingDto($travel, 1);
@@ -203,7 +203,7 @@ class BookingPriceCalculatorServiceTest extends TestCase
$travel->rooms = [$room];
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = 1;
$roomSelection->id = 1;
$roomSelection->quantity = 0; // Zero quantity - not selected
$bookingDto = new BookingDto($travel, 1);
+1 -1
View File
@@ -200,7 +200,7 @@ class RoomAssignmentServiceTest extends TestCase
foreach ($selections as $selection) {
$roomSelectionDto = new RoomSelectionDto();
$roomSelectionDto->roomId = $selection['roomId'];
$roomSelectionDto->id = $selection['roomId'];
$roomSelectionDto->quantity = $selection['quantity'];
if (isset($selection['capacity'])) {
$roomSelectionDto->capacity = $selection['capacity'];
@@ -146,7 +146,7 @@ class RoomSelectionValidatorTest extends ConstraintValidatorTestCase
$travel->rooms[] = $room;
$roomSelection = new RoomSelectionDto();
$roomSelection->roomId = $data['id'];
$roomSelection->id = $data['id'];
$roomSelection->quantity = $data['quantity'];
$roomSelections[] = $roomSelection;
}