feat: extended pricing breakdown on confirmation page

This commit is contained in:
Björn Fromme
2025-10-18 17:59:42 +02:00
parent e6c47019d5
commit ee69346eb4
3 changed files with 176 additions and 30 deletions
+23 -4
View File
@@ -8,7 +8,11 @@
1. **Step 1**: Room selection and dates (`Create\Step1Controller`) 1. **Step 1**: Room selection and dates (`Create\Step1Controller`)
2. **Step 2**: Participant details with card-based UI (`Create\Step2Controller`) 2. **Step 2**: Participant details with card-based UI (`Create\Step2Controller`)
3. **Step 3**: Payment method selection (`Create\Step3Controller`) 3. **Step 3**: Payment method selection (`Create\Step3Controller`)
4. **Step 4**: Final confirmation and submission to BPN API (`Create\Step4Controller`) 4. **Step 4**: Final confirmation with comprehensive pricing breakdown and submission to BPN API (`Create\Step4Controller`)
- Displays complete pricing breakdown by category (rooms, services grouped by type)
- Shows per-participant total pricing in card headers
- Displays individual service prices inline with each booked service
- Three-level pricing transparency: aggregate, participant, and service-level
5. **Edit Flow**: Similar card-based UI for existing bookings (`Edit\IndexController`) 5. **Edit Flow**: Similar card-based UI for existing bookings (`Edit\IndexController`)
### Card-Based UI Pattern (Production) ### Card-Based UI Pattern (Production)
@@ -46,7 +50,12 @@
### Service Layer (`src/Service/`) ### Service Layer (`src/Service/`)
- `BookingService` - Core booking workflow - `BookingService` - Core booking workflow
- `BookingPriceCalculatorService` - Real-time pricing - `BookingPriceCalculatorService` - Comprehensive pricing calculations
- `getPricingBreakdown()` - Complete pricing breakdown with rooms and services grouped by type
- `calculateAllParticipantIndividualPrices()` - Per-participant total pricing
- `calculateIndividualParticipantPrice()` - Single participant total (room + all services)
- `calculateServicePricing()` - Service aggregation with grouping by subtype
- Powers sidebar summary and Step 4 confirmation pricing display
- `BookingFingerprintService` - Dirty state detection for edit mode - `BookingFingerprintService` - Dirty state detection for edit mode
- `TravelDataService` - API integration and caching - `TravelDataService` - API integration and caching
- `ParticipantCardDataService` - Card display data - `ParticipantCardDataService` - Card display data
@@ -285,7 +294,12 @@ Edit mode session requires proper cleanup to prevent dirty state persistence:
- `Step1Controller.php` - Room selection and dates - `Step1Controller.php` - Room selection and dates
- `Step2Controller.php` - Participant details with card-based UI - `Step2Controller.php` - Participant details with card-based UI
- `Step3Controller.php` - Payment method selection - `Step3Controller.php` - Payment method selection
- `Step4Controller.php` - Final confirmation and API submission - `Step4Controller.php` - Final confirmation with comprehensive pricing display and API submission
- Integrates `BookingPriceCalculatorService` for complete pricing breakdown
- Calculates per-participant individual prices via `calculateAllParticipantIndividualPrices()`
- Template displays three-level pricing: aggregate breakdown, per-participant totals, and inline service prices
- All service prices shown in blue (text-blue-700) with German formatting
- Zero prices hidden per project standards
- `SuccessController.php` - Success page after booking completion - `SuccessController.php` - Success page after booking completion
**Edit Namespace** (`src/Controller/Booking/Edit/`): **Edit Namespace** (`src/Controller/Booking/Edit/`):
@@ -312,7 +326,12 @@ Edit mode session requires proper cleanup to prevent dirty state persistence:
- `templates/booking/create/step_1.html.twig` - Room selection - `templates/booking/create/step_1.html.twig` - Room selection
- `templates/booking/create/step_2.html.twig` - Participant cards - `templates/booking/create/step_2.html.twig` - Participant cards
- `templates/booking/create/step_3.html.twig` - Payment method - `templates/booking/create/step_3.html.twig` - Payment method
- `templates/booking/create/step_4.html.twig` - Confirmation - `templates/booking/create/step_4.html.twig` - Final confirmation with comprehensive pricing display
- **Aggregate pricing breakdown**: Rooms and services grouped by type with totals
- **Per-participant cards**: Individual total price in header, all personal details and service selections
- **Inline service pricing**: Each service shows price in blue (€X,XX format) next to label
- **Payment summary**: Selected payment method and bank details (if debit)
- **Confirmation checkbox**: Final acceptance before API submission
- `templates/booking/create/success.html.twig` - Success page - `templates/booking/create/success.html.twig` - Success page
- `templates/booking/create/error.html.twig` - Error page - `templates/booking/create/error.html.twig` - Error page
@@ -11,6 +11,7 @@ use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
use App\Form\BookingCreateStep4Type; use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Htmx\HxTrait; use App\Htmx\HxTrait;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService; use App\Service\BookingService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -30,6 +31,7 @@ class Step4Controller extends AbstractController
public function __construct( public function __construct(
private readonly BookingService $bookingService, private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly ApiClient $apiClient, private readonly ApiClient $apiClient,
private readonly LoggerInterface $logger, private readonly LoggerInterface $logger,
) { ) {
@@ -118,6 +120,7 @@ class Step4Controller extends AbstractController
'bookingCreateDto' => $bookingCreateDto, 'bookingCreateDto' => $bookingCreateDto,
'form' => $form->createView(), 'form' => $form->createView(),
...$this->getSummaryVariables($bookingCreateDto), ...$this->getSummaryVariables($bookingCreateDto),
'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto),
]); ]);
} }
} }
+150 -26
View File
@@ -25,18 +25,74 @@
</dl> </dl>
</div> </div>
{# Rooms Summary #} {# Detailed Pricing Breakdown #}
<div class="mb-8 p-6 bg-gray-50 rounded-lg border border-gray-200"> <div class="mb-8 p-6 bg-blue-50 rounded-lg border border-blue-200">
<h3 class="text-xl font-semibold mb-4">Zimmer / Unterkünfte</h3> <h3 class="text-xl font-semibold mb-6 text-blue-900">Preisübersicht</h3>
{# Rooms Section #}
{% if pricingData.rooms is not empty %} {% if pricingData.rooms is not empty %}
<ul class="space-y-2"> <div class="mb-6 pb-6 border-b border-blue-200">
{% for roomPricing in pricingData.rooms %} <h4 class="font-semibold text-lg mb-3 text-blue-800">Unterkunft</h4>
<li class="flex justify-between"> <div class="space-y-2">
<span>{{ roomPricing.quantity }}x {{ roomPricing.label }}</span> {% for roomPricing in pricingData.rooms %}
<span class="font-semibold">{{ roomPricing.totalPrice|number_format(2, ',', '.') }} €</span> <div class="flex justify-between items-start">
</li> <div class="text-sm text-gray-700">
<span class="font-medium">{{ roomPricing.quantity }}x {{ roomPricing.label }}</span>
{% if assignmentCounts is defined and assignmentCounts[roomPricing.roomId] is defined %}
<span class="block text-xs text-gray-600">{{ assignmentCounts[roomPricing.roomId] }} Person(en) belegt</span>
{% endif %}
</div>
<div class="text-right">
<div class="text-gray-900 font-semibold">
{{ roomPricing.totalPrice|number_format(2, ',', '.') }}
</div>
<div class="text-xs text-gray-600">
{{ roomPricing.unitPrice|number_format(2, ',', '.') }} pro Person
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# Services Section #}
{% if pricingData.services is not empty %}
<div class="mb-6 pb-6 border-b border-blue-200">
<h4 class="font-semibold text-lg mb-3 text-blue-800">Leistungen</h4>
{% for serviceGroup in pricingData.services %}
<div class="mb-4 last:mb-0">
<h5 class="font-medium text-sm text-gray-700 mb-2 uppercase tracking-wide">{{ serviceGroup.groupName }}</h5>
<div class="space-y-1.5 ml-4">
{% for servicePricing in serviceGroup.services %}
<div class="flex justify-between items-center text-sm">
<span class="text-gray-700">
{{ servicePricing.participantCount }}x {{ servicePricing.label }}
{% if servicePricing.unitPrice is not null %}
<span class="text-xs text-gray-500">(€{{ servicePricing.unitPrice|number_format(2, ',', '.') }} pro Person)</span>
{% endif %}
</span>
<span class="font-medium text-gray-900">
{{ servicePricing.totalPrice|number_format(2, ',', '.') }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %} {% endfor %}
</ul> </div>
{% endif %}
{# Grand Total #}
{% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
<div class="pt-4">
<div class="flex justify-between items-center">
<span class="font-bold text-xl text-blue-900">Gesamtpreis:</span>
<span class="font-bold text-2xl text-blue-900">
{{ pricingData.grandTotal|number_format(2, ',', '.') }}
</span>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
@@ -45,10 +101,18 @@
<h3 class="text-xl font-semibold mb-4">Teilnehmer ({{ participantsCount }})</h3> <h3 class="text-xl font-semibold mb-4">Teilnehmer ({{ participantsCount }})</h3>
{% for participant in bookingCreateDto.participants %} {% for participant in bookingCreateDto.participants %}
<div class="mb-6 pb-6 {% if not loop.last %}border-b border-gray-300{% endif %}"> <div class="mb-6 pb-6 {% if not loop.last %}border-b border-gray-300{% endif %}">
<h4 class="font-semibold text-lg mb-3"> <div class="flex justify-between items-start mb-3">
{{ participant.firstName }} {{ participant.lastName }} <h4 class="font-semibold text-lg">
{% if loop.first %}<span class="text-sm text-gray-600">(Anmelder)</span>{% endif %} {{ participant.firstName }} {{ participant.lastName }}
</h4> {% if loop.first %}<span class="text-sm text-gray-600">(Anmelder)</span>{% endif %}
</h4>
{% if participantPrices is defined and participantPrices[loop.index0] is defined %}
<div class="text-right">
<div class="text-xs text-gray-600 uppercase tracking-wide">Preis</div>
<div class="font-bold text-lg text-blue-700">€{{ participantPrices[loop.index0]|number_format(2, ',', '.') }}</div>
</div>
{% endif %}
</div>
<dl class="grid grid-cols-2 gap-4 text-sm"> <dl class="grid grid-cols-2 gap-4 text-sm">
{# Personal Data #} {# Personal Data #}
@@ -102,7 +166,12 @@
{% if assignedRoom %} {% if assignedRoom %}
<div> <div>
<dt class="text-gray-600 font-semibold">Zimmer</dt> <dt class="text-gray-600 font-semibold">Zimmer</dt>
<dd>{{ assignedRoom.label }}</dd> <dd>
{{ assignedRoom.label }}
{% if assignedRoom.price is not null and assignedRoom.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ assignedRoom.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
@@ -118,13 +187,23 @@
{% if participant.transportationOutbound %} {% if participant.transportationOutbound %}
<div> <div>
<dt class="text-gray-600 font-semibold">Anreise</dt> <dt class="text-gray-600 font-semibold">Anreise</dt>
<dd>{{ participant.transportationOutbound.label }}</dd> <dd>
{{ participant.transportationOutbound.label }}
{% if participant.transportationOutbound.price is not null and participant.transportationOutbound.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.transportationOutbound.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
{% if participant.transportationInbound %} {% if participant.transportationInbound %}
<div> <div>
<dt class="text-gray-600 font-semibold">Abreise</dt> <dt class="text-gray-600 font-semibold">Abreise</dt>
<dd>{{ participant.transportationInbound.label }}</dd> <dd>
{{ participant.transportationInbound.label }}
{% if participant.transportationInbound.price is not null and participant.transportationInbound.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.transportationInbound.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
@@ -132,7 +211,12 @@
{% if participant.pickup %} {% if participant.pickup %}
<div> <div>
<dt class="text-gray-600 font-semibold">Zu- und Ausstieg</dt> <dt class="text-gray-600 font-semibold">Zu- und Ausstieg</dt>
<dd>{{ participant.pickup.label }}</dd> <dd>
{{ participant.pickup.label }}
{% if participant.pickup.price is not null and participant.pickup.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.pickup.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
@@ -140,7 +224,12 @@
{% if participant.parking %} {% if participant.parking %}
<div> <div>
<dt class="text-gray-600 font-semibold">Parkplatz</dt> <dt class="text-gray-600 font-semibold">Parkplatz</dt>
<dd>Ja</dd> <dd>
Ja
{% if participant.parkingService is not null and participant.parkingService.price is not null and participant.parkingService.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.parkingService.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
{% if participant.licensePlate %} {% if participant.licensePlate %}
@@ -157,7 +246,12 @@
<dd> <dd>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{% for boardItem in participant.board %} {% for boardItem in participant.board %}
<li>{{ boardItem.label }}</li> <li>
{{ boardItem.label }}
{% if boardItem.price is not null and boardItem.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ boardItem.price|number_format(2, ',', '.') }})</span>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</dd> </dd>
@@ -168,7 +262,12 @@
{% if participant.skiPass %} {% if participant.skiPass %}
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-gray-600 font-semibold">Skipass</dt> <dt class="text-gray-600 font-semibold">Skipass</dt>
<dd>{{ participant.skiPass.label }}</dd> <dd>
{{ participant.skiPass.label }}
{% if participant.skiPass.price is not null and participant.skiPass.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.skiPass.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
@@ -179,7 +278,12 @@
<dd> <dd>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{% for rental in participant.rentals %} {% for rental in participant.rentals %}
<li>{{ rental.label }}</li> <li>
{{ rental.label }}
{% if rental.price is not null and rental.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ rental.price|number_format(2, ',', '.') }})</span>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</dd> </dd>
@@ -190,7 +294,12 @@
{% if participant.rentalInsuranceSelected and participant.rentalInsurance %} {% if participant.rentalInsuranceSelected and participant.rentalInsurance %}
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-gray-600 font-semibold">Leihmaterial-Versicherung</dt> <dt class="text-gray-600 font-semibold">Leihmaterial-Versicherung</dt>
<dd>{{ participant.rentalInsurance.label }}</dd> <dd>
{{ participant.rentalInsurance.label }}
{% if participant.rentalInsurance.price is not null and participant.rentalInsurance.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.rentalInsurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
@@ -215,7 +324,12 @@
<dd> <dd>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{% for course in participant.courses %} {% for course in participant.courses %}
<li>{{ course.label }}</li> <li>
{{ course.label }}
{% if course.price is not null and course.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ course.price|number_format(2, ',', '.') }})</span>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</dd> </dd>
@@ -229,7 +343,12 @@
<dd> <dd>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
{% for service in participant.additionalServices %} {% for service in participant.additionalServices %}
<li>{{ service.label }}</li> <li>
{{ service.label }}
{% if service.price is not null and service.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ service.price|number_format(2, ',', '.') }})</span>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
</dd> </dd>
@@ -240,7 +359,12 @@
{% if participant.insurance %} {% if participant.insurance %}
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-gray-600 font-semibold">Reiseversicherung</dt> <dt class="text-gray-600 font-semibold">Reiseversicherung</dt>
<dd>{{ participant.insurance.label }}</dd> <dd>
{{ participant.insurance.label }}
{% if participant.insurance.price is not null and participant.insurance.price != 0 %}
<span class="text-blue-700 font-semibold ml-1">(€{{ participant.insurance.price|number_format(2, ',', '.') }})</span>
{% endif %}
</dd>
</div> </div>
{% endif %} {% endif %}
</dl> </dl>