diff --git a/REFACTORING_PARTICIPANT_CARDS.md b/REFACTORING_PARTICIPANT_CARDS.md deleted file mode 100644 index 5569391..0000000 --- a/REFACTORING_PARTICIPANT_CARDS.md +++ /dev/null @@ -1,1427 +0,0 @@ -# Participant Card View Refactoring Plan - -## Overview - -Transform create (step 2) and edit flows from accordion-style "all forms at once" to a card overview with lazy-loaded individual participant forms. This addresses UX and performance issues with large groups (50+ participants). - -**Status**: Planning Complete - Ready for Implementation (SIMPLIFIED ARCHITECTURE - 2025-01-14) - -## Architecture Revision (2025-01-14) - -**Major Simplification**: Removed wrapper forms and DTOs! - -**What Changed:** -- ❌ Removed: `ParticipantFormDto` - not needed -- ❌ Removed: `ParticipantFormType` wrapper - not needed -- ✅ Simplified: Pass `BookingDto` via form options (`booking_context`) -- ✅ Simplified: Reuse `BookingParticipantType` directly -- ✅ Simplified: Field handlers called manually in controller - -**Why:** -The initial plan had unnecessary abstraction layers. We realized we can simply pass the `BookingDto` as a form option instead of wrapping everything. This is cleaner, more explicit, and easier to understand. - -## Implementation Discovery: Field Handler Mode Awareness (2025-01-14) - -**Problem Encountered:** -When promoting participant forms to standalone (autonomous) forms in the card flow, field handlers needed to be called from within `BookingParticipantType`'s PRE_SUBMIT event. This created a critical issue in edit mode: - -- The BPN API **does not return insurance data** for privacy/security reasons -- In edit mode, the insurance field handler would see missing insurance data in submitted forms -- The handler would incorrectly interpret this as "user wants to clear insurance" -- Result: Insurance data would be lost during edit operations - -**Root Cause:** -Field handlers had no way to distinguish between: -1. **Create mode**: Missing data = user didn't select anything (clear it) -2. **Edit mode**: Missing data = API didn't provide it (preserve existing value) - -**Solution Implemented:** -Added mode awareness to the field handler system: - -```php -// Interface change -public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool; - -// Insurance handler implementation -public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool -{ - // Skip processing in edit mode - API doesn't return insurance data - if ($mode === BookingDto::MODE_EDIT) { - return false; - } - - // Process normally in create mode - return true; -} -``` - -**Architecture Changes:** -- ✅ Added `$mode` parameter to `ParticipantFieldHandlerInterface::shouldProcess()` -- ✅ Updated `AbstractParticipantFieldHandler` with mode parameter -- ✅ Added helper methods: `isEditMode()`, `isCreateMode()` for future use -- ✅ Updated `ParticipantFieldHandlerRegistry` to pass `$bookingDto->mode` to handlers -- ✅ Updated all 13+ field handlers to accept mode parameter -- ✅ Insurance handler skips processing entirely in edit mode -- ✅ All other handlers continue processing normally in both modes - -**Why Only Insurance Needs Special Treatment:** -- **Insurance**: API doesn't return data → must skip processing in edit mode -- **Services** (ski pass, rentals, courses): API returns data → process normally -- **Transportation**: API returns data → process normally -- **Room assignments**: API returns data → process normally -- **All other fields**: API returns data → process normally - -**Benefits:** -- ✅ Insurance data preserved correctly in edit mode -- ✅ Explicit mode-aware behavior (no implicit assumptions) -- ✅ Clean separation: only handlers that need it check mode -- ✅ Future-proof: easy to add mode-specific logic to other handlers if needed -- ✅ Tests updated to verify edit mode behavior - -**Implementation Status:** ✅ **COMPLETED** (2025-01-14) - -## Goals - -- **Performance**: Lazy load forms on demand instead of rendering all at once -- **UX**: Cleaner overview with card-based navigation -- **Scalability**: Handle 50+ participant bookings efficiently -- **Code Reuse**: Maximize shared code between create and edit flows -- **Safe Migration**: Keep old flows intact during development - -## Architecture Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| **Form Rendering** | Replace entire content area (Option C) | Best for mobile, prevents concurrency issues | -| **Form Submission** | Explicit "Speichern" button | Not all fields trigger refresh, cleaner UX | -| **Card Content** | Name, room, price only | Minimal, focused information | -| **Validation** | On form submission only | No card-level indicators needed | -| **Card Price Updates** | Full re-render on return to cards | Cards not visible during form editing | -| **Room Assignment** | Keep in participant form | Existing behavior maintained | -| **Migration Strategy** | Separate controllers (-v2 routes) | Safe parallel development, easy comparison | -| **Lazy Loading** | Forms loaded via HTMX on demand | Critical for performance with 50+ participants | -| **BookingDto Context** | Pass via form options | Simple, explicit, no wrappers needed | - -## Key Simplification - -**No wrapper forms or DTOs needed!** We pass `BookingDto` directly via form options: - -```php -// Controller creates form with explicit context -$form = $this->createForm(BookingParticipantType::class, $participant, [ - 'booking_context' => $bookingDto, // Passed explicitly - 'edit_mode' => false, -]); -``` - -**Benefits:** -- ✅ No `ParticipantFormDto` wrapper -- ✅ No `ParticipantFormType` wrapper -- ✅ Reuse existing `BookingParticipantType` directly -- ✅ Explicit data flow (no "magic" form tree traversal) -- ✅ Field handlers called manually in controller (like current Step 2) -- ✅ Clean, maintainable, easy to understand - ---- - -## Phase 1: Shared Foundation - -### 1.1 Create `ParticipantCardDataService` - -**File**: `src/Service/ParticipantCardDataService.php` - -**Purpose**: Extract card display data from BookingDto - -**Methods**: - -```php -/** - * Get card data for a single participant - * - * @return array{name: string, roomName: string, price: string} - */ -public function getCardData(BookingDto $bookingDto, int $index): array - -/** - * Get card data for all participants - * - * @return array - */ -public function getAllCardsData(BookingDto $bookingDto): array -``` - -**Implementation Details**: -- **Name**: `$participant->firstName . ' ' . $participant->lastName` or fallback to "Teilnehmer {index+1}" -- **Room**: Look up from `$bookingDto->travel->getRoomById($participant->assignedRoomId)` -- **Price**: Use `BookingPriceCalculatorService::calculateAllParticipantIndividualPrices()`, format as `number_format($price, 2, ',', '.') . ' €'` - -**Dependencies**: -- `BookingPriceCalculatorService` (existing) -- Access to Travel model for room lookups - -**Test Coverage**: -- Unit test: Verify name fallback for participants without names -- Unit test: Verify price formatting -- Unit test: Verify room name lookup -- Unit test: Handle missing room assignment gracefully - -**Todos**: -- [ ] Create service class with DI configuration -- [ ] Implement `getCardData()` method -- [ ] Implement `getAllCardsData()` method -- [ ] Write unit tests -- [ ] Handle edge cases (missing room, missing names) - ---- - -### 1.2 Extend `ParticipantFieldHandlerRegistry` - -**File**: `src/Form/Service/ParticipantFieldHandlerRegistry.php` - -**New Method**: - -```php -/** - * Process field handlers for a single participant - * - * @param array $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 -``` - -**Implementation**: -- Extract loop body from existing `processFields()` method -- Apply all handlers to single participant in dependency order -- Reuse existing handler sorting and execution logic - -**Todos**: -- [ ] Add `processFieldsForParticipant()` method to registry -- [ ] Extract handler execution logic from loop -- [ ] Maintain dependency order execution -- [ ] Test with existing field handlers -- [ ] Verify field sync logic works for single participant - ---- - -### 1.3 Update `BookingParticipantType` to Accept `booking_context` Option - -**File**: `src/Form/BookingParticipantType.php` - -**Purpose**: Make form type autonomous - it processes its own field handlers when used standalone - -**Why This Simplification**: -In the card flow, there's no parent form to orchestrate field handler execution. The form type becomes **self-contained and autonomous** - it receives `BookingDto` via options and processes its own field handlers in PRE_SUBMIT. - -**Architecture Benefits**: -- ✅ No wrapper forms needed -- ✅ No extra DTOs needed -- ✅ No parent form needed -- ✅ Explicit data flow via options -- ✅ Field handlers called automatically in PRE_SUBMIT -- ✅ Controller stays thin - just creates form and handles result -- ✅ Form type is responsible for its own data processing - -**Key Insight**: We're **promoting the child form to be in charge**. It's no longer a dumb child controlled by a parent - it's a smart, autonomous form that handles everything itself. - -**Modification to `__construct()` - Add Field Handler Registry**: - -```php -public function __construct( - private readonly FieldOptionsProviderInterface $fieldOptionsProvider, - private readonly CreateFieldStateProvider $createFieldStateProvider, - private readonly EditFieldStateProvider $editFieldStateProvider, - private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, // NEW: for autonomous processing -) { -} -``` - -**Modification to `configureOptions()`**: - -```php -public function configureOptions(OptionsResolver $resolver): void -{ - $resolver->setDefaults([ - 'data_class' => ParticipantDto::class, - 'selected_rooms' => [], - 'edit_mode' => false, - 'booking_context' => null, // NEW: Optional BookingDto for card flows - ]); - - $resolver->setAllowedTypes('selected_rooms', 'array'); - $resolver->setAllowedTypes('edit_mode', 'bool'); - $resolver->setAllowedTypes('booking_context', ['null', BookingDto::class]); -} -``` - -**Modification to `buildForm()`**: - -```php -public function buildForm(FormBuilderInterface $builder, array $options): void -{ - // Select field state provider based on edit_mode option - $this->fieldStateProvider = $options['edit_mode'] - ? $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) use ($bookingContext) { - $this->onPreSetData($event, $bookingContext); - }) - ->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); - }); -} - -/** - * Process 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 - ); -} - -private function onPreSetData(FormEvent $event, ?BookingDto $bookingContext): void -{ - /** @var ParticipantDto|null $participantData */ - $participantData = $event->getData(); - $form = $event->getForm(); - - if (null === $participantData) { - return; - } - - // 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; - } - - // Add base fields with states applied - $this->addBaseFields($form, $bookingDto, $participantData->index); - - // Add dynamic fields - $this->addDynamicFields($form, $bookingDto, $participantData->index); -} - -private function onPreSubmit(FormEvent $event, ?BookingDto $bookingContext): void -{ - $submittedData = $event->getData(); - $form = $event->getForm(); - - if (false === is_array($submittedData)) { - return; - } - - // 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; - } - - // Get participant index from form data - $participantData = $form->getData(); - if (null === $participantData || false === property_exists($participantData, 'index')) { - return; - } - - // Rebuild all fields with updated states based on submitted data - $this->rebuildFieldsWithStates($form, $bookingDto, $participantData->index, $submittedData); -} -``` - -**Usage in Controllers**: - -```php -// Card flow: Pass booking context explicitly -$form = $this->createForm(BookingParticipantType::class, $participant, [ - 'booking_context' => $bookingDto, - 'edit_mode' => false, -]); -``` - -**Todos**: -- [ ] Add `ParticipantFieldHandlerRegistry` to constructor (DI) -- [ ] Add `booking_context` option to `configureOptions()` -- [ ] Update `buildForm()` to capture `booking_context` and add PRE_SUBMIT listener -- [ ] Add `processFieldHandlers()` private method to call registry -- [ ] Update `onPreSetData()` to accept and use `$bookingContext` parameter -- [ ] Update `onPreSubmit()` to accept and use `$bookingContext` parameter -- [ ] Test with card flow (booking_context provided) -- [ ] Verify field handlers execute automatically -- [ ] Verify field state providers work correctly - ---- - -### 1.4 Create `ParticipantCardFlowTrait` - -**File**: `src/Controller/Booking/ParticipantCardFlowTrait.php` - -**Purpose**: Share common controller logic between create and edit flows - -**Methods**: - -```php -/** - * Load BookingDto from session or throw exception - */ -private function loadBookingDtoOrFail(Request $request, string $mode): BookingDto - -/** - * Generate card data for all participants - */ -private function generateAllCardsData(BookingDto $bookingDto): array - -/** - * Create form for single participant - */ -private function createParticipantForm( - BookingDto $bookingDto, - int $index, - array $options = [] -): FormInterface - -/** - * Calculate summary data (pricing, room counts, etc.) - */ -private function calculateSummaryData(BookingDto $bookingDto): array - -/** - * Process single participant form refresh - */ -private function handleParticipantRefresh( - Request $request, - BookingDto $bookingDto, - int $index, - string $refreshRouteName -): Response -``` - -**Todos**: -- [ ] Create trait file -- [ ] Implement `loadBookingDtoOrFail()` -- [ ] Implement `generateAllCardsData()` using `ParticipantCardDataService` -- [ ] Implement `createParticipantForm()` -- [ ] Implement `calculateSummaryData()` -- [ ] Implement `handleParticipantRefresh()` with OOB swap logic -- [ ] Add type hints and PHPDoc - ---- - -## Phase 2: Create Flow (Step 2 Refactored) - -### 2.1 Create `CreateStep2RefactoredController` - -**File**: `src/Controller/Booking/CreateStep2RefactoredController.php` - -**Routes**: - -| Method | Route | Name | Purpose | -|--------|-------|------|---------| -| GET/POST | `/bookings/create/participants-v2` | `app_booking_create_step_2_v2_cards` | Display card grid | -| GET/POST | `/bookings/create/participants-v2/{index}` | `app_booking_create_step_2_v2_participant` | Show/submit participant form | -| POST | `/bookings/create/participants-v2/{index}/refresh` | `app_booking_create_step_2_v2_participant_refresh` | HTMX refresh without validation | - -**Controller Structure**: - -```php -class CreateStep2RefactoredController extends AbstractController -{ - use BookingCreateTrait; - use BookingExceptionHandlerTrait; - use HxTrait; - use ParticipantCardFlowTrait; - - public function __construct( - private readonly BookingService $bookingService, - private readonly BookingPriceCalculatorService $priceCalculator, - private readonly TravelDataService $travelDataService, - private readonly RoomAssignmentService $roomAssignmentService, - private readonly ParticipantCardDataService $participantCardService, - private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, - ) { - } -} -``` - -**Action: `showCards(Request $request): Response`** - -Implementation steps: -1. Load BookingDto from session (reuse `getOrCreateBookingCreateDto()`) -2. Enrich with fresh availability data -3. Validate step access -4. Ensure correct number of participants -5. Auto-assign rooms if needed -6. Preselect mandatory services -7. Save BookingDto to session -8. Generate all cards data via `ParticipantCardDataService` -9. Calculate summary data -10. Render `create_step_2_v2_cards.html.twig` - -**Action: `editParticipant(int $index, Request $request): Response`** - -Implementation steps: -1. Load BookingDto from session -2. Validate participant index -3. Create form for `$bookingDto->participants[$index]` using `BookingParticipantType` with `booking_context` option: - ```php - $form = $this->createForm(BookingParticipantType::class, $participant, [ - 'booking_context' => $bookingDto, - 'edit_mode' => false, - 'validation_groups' => ['booking_create_step_2'], - ]); - ``` -4. `$form->handleRequest($request)` - - **Note**: Field handlers are called automatically by the form in PRE_SUBMIT -5. If submitted and valid: - - Save BookingDto to session - - HTMX redirect to cards view -6. Calculate summary data for sidebar -7. Render `_participant_form_standalone.html.twig` with form and summary - -**Action: `refreshParticipantForm(int $index, Request $request): Response`** - -Implementation steps: -1. Load BookingDto from session -2. Enrich with fresh availability data -3. Create form with `booking_context` option and `validation_groups: false`: - ```php - $form = $this->createForm(BookingParticipantType::class, $participant, [ - 'booking_context' => $bookingDto, - 'edit_mode' => false, - 'validation_groups' => false, - ]); - ``` -4. `$form->handleRequest($request)` -5. Extract participant data from submitted form -6. Process field handlers via `processFieldsForParticipant()` -7. Preselect mandatory services -8. Save BookingDto to session -9. Collect notifications from participant DTO -10. Calculate summary data -11. Render form + sidebar using `htmxOobResponse()` with blocks: - - `participant_form` (target) - - `booking_summary` (OOB swap) -12. Add notifications to HX-Trigger header if present - -**Todos**: -- [ ] Create controller class with DI -- [ ] Implement `showCards()` action -- [ ] Implement `editParticipant()` action -- [ ] Implement `refreshParticipantForm()` action -- [ ] Add route annotations -- [ ] Handle edge cases (invalid index, session expiry) -- [ ] Add error handling and logging - ---- - -### 2.2 Templates for Create Flow - -**Main Template: `templates/booking/create_step_2_v2_cards.html.twig`** - -Structure: -```twig -{% extends 'layout.html.twig' %} - -{% block content %} - {% include '_partials/_flashes.html.twig' %} -

Neue Buchung

- -
- {# Main content area - cards grid #} -
-

Teilnehmer

- -
- {% for participant in bookingDto.participants %} - {% include 'booking/_participant_card.html.twig' with { - 'cardData': cardsData[loop.index0], - 'index': loop.index0, - 'mode': 'create' - } %} - {% endfor %} -
- -
- - Zurück - - -
-
- - {# Sidebar summary #} - {% block booking_summary %} -
- {% include 'booking/_summary.html.twig' with { - 'bookingCreateDto': bookingDto, - 'participantCount': participantsCount, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts - } %} -
- {% endblock %} -
-{% endblock %} -``` - -**Todos**: -- [ ] Create main template file -- [ ] Add grid layout structure -- [ ] Include card loop -- [ ] Add navigation buttons -- [ ] Include sidebar summary - ---- - -**Card Partial: `templates/booking/_participant_card.html.twig`** - -Structure: -```twig -{# Compact participant card with name, room, price, and edit button #} -
-
-

{{ cardData.name }}

-

{{ cardData.roomName }}

-
-
- {{ cardData.price }} - -
-
-``` - -**Features**: -- Compact layout -- Name (or "Teilnehmer X" fallback) -- Room name -- Price formatted as "450,00 €" -- "Bearbeiten" button with HTMX attributes -- Target replaces entire `#main-content` area - -**Todos**: -- [ ] Create card template -- [ ] Add HTMX attributes for navigation -- [ ] Style with TailwindCSS -- [ ] Test responsive layout -- [ ] Handle long names gracefully - ---- - -**Form Standalone: `templates/booking/_participant_form_standalone.html.twig`** - -Structure: -```twig -{# Standalone participant form view (replaces main content area) #} -
-

{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}

- - {{ form_start(form, { - 'attr': { - 'novalidate': 'novalidate', - 'hx-post': path('app_booking_create_step_2_v2_participant', {index: participantIndex}), - 'hx-target': '#main-content', - 'hx-swap': 'innerHTML' - } - }) }} - - {% block participant_form %} -
- {# Personal data section #} -
- {{ form_row(form.firstName) }} - {{ form_row(form.lastName) }} - {{ form_row(form.dateOfBirth, { - 'attr': { - 'hx-trigger': 'change', - 'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}), - 'hx-swap': 'none' - } - }) }} - {{ form_row(form.gender) }} - {{ form_row(form.nationality) }} -
- - {# Contact information #} -
- {{ form_row(form.email) }} - {{ form_row(form.mobile) }} -
- - {# Address #} - {% if form.address is defined %} -
- {{ form_row(form.address.street) }} - {{ form_row(form.address.postCode) }} - {{ form_row(form.address.city) }} - {{ form_row(form.address.country) }} -
- {% endif %} - - {# Body dimensions #} - {% if form.bodyDimensions is defined %} -
- {{ form_row(form.bodyDimensions.height) }} - {{ form_row(form.bodyDimensions.shoeSize) }} - {{ form_row(form.bodyDimensions.weight) }} -
- {% endif %} - - {# Room assignment #} -
- {{ form_row(form.assignedRoomId, { - 'attr': { - 'hx-trigger': 'change', - 'hx-post': path('app_booking_create_step_2_v2_participant_refresh', {index: participantIndex}), - 'hx-swap': 'none' - } - }) }} - {% if form.remarksRoom is defined %} - {{ form_row(form.remarksRoom) }} - {% endif %} -
- - {# Service selection - same structure as current templates #} - {# Age eligibility checks, conditional field rendering, etc. #} - {# Transportation, insurance, etc. #} -
- {% endblock %} - -
- - -
- - {{ form_rest(form) }} - {{ form_end(form) }} -
-``` - -**Key Features**: -- Reuses existing form field structure from `create_step_2.html.twig` -- HTMX attributes on fields that trigger refresh -- "Abbrechen" returns to cards (GET request, no save) -- "Speichern" submits form (POST request, validation, save on success) -- Form action posts to same route (Symfony convention) - -**Todos**: -- [ ] Create form template -- [ ] Copy form field structure from current template -- [ ] Add HTMX attributes for refresh triggers -- [ ] Add navigation buttons (Abbrechen/Speichern) -- [ ] Test all conditional field logic -- [ ] Ensure age eligibility checks work -- [ ] Verify field state providers work correctly - ---- - -## Phase 3: Edit Flow Refactored - -### 3.1 Create `EditRefactoredController` - -**File**: `src/Controller/Booking/EditRefactoredController.php` - -**Routes**: - -| Method | Route | Name | Purpose | -|--------|-------|------|---------| -| GET/POST | `/bookings/{id}/edit-v2` | `app_booking_edit_v2_cards` | Display card grid | -| GET/POST | `/bookings/{id}/edit-v2/participants/{index}` | `app_booking_edit_v2_participant` | Show/submit participant form | -| POST | `/bookings/{id}/edit-v2/participants/{index}/refresh` | `app_booking_edit_v2_participant_refresh` | HTMX refresh | -| POST | `/bookings/{id}/edit-v2/reload` | `app_booking_edit_v2_reload` | Discard changes, reload from API | - -**Differences from Create Flow**: -- Load booking data from API on first visit (via `BookingDataTrait`) -- Store in session with `MODE_EDIT` -- Handle canceled participants (status 'S'): - - Show "storniert" badge on card - - Disable "Bearbeiten" button - - Show surcharges info when clicked -- Final submission calls `ApiClient::updateBooking()` instead of navigating to step 3 -- Apply mutability constraints via `EditFieldStateProvider` - -**Controller Structure**: - -```php -class EditRefactoredController extends AbstractController -{ - use BookingDataTrait; - use HxTrait; - use ParticipantCardFlowTrait; - - public function __construct( - private readonly ApiClient $apiClient, - private readonly BookingDataProcessor $bookingDataProcessor, - private readonly TravelDataService $travelDataService, - private readonly BookingService $bookingService, - private readonly BookingPriceCalculatorService $priceCalculator, - private readonly ParticipantCardDataService $participantCardService, - private readonly ParticipantFieldHandlerRegistry $fieldHandlerRegistry, - private readonly CacheInterface $cache, - private readonly Security $security, - private readonly Crypt $crypt, - private readonly LoggerInterface $logger, - ) { - } -} -``` - -**Action Implementations**: Similar to create flow, with edit-specific logic - -**Todos**: -- [ ] Create controller class with DI -- [ ] Implement `showCards()` action (load from API on first visit) -- [ ] Implement `editParticipant()` action (handle canceled participants) -- [ ] Implement `refreshParticipantForm()` action (apply mutability constraints) -- [ ] Implement `reloadFromApi()` action (discard session changes) -- [ ] Add route annotations -- [ ] Handle edit-specific edge cases -- [ ] Test API integration - ---- - -### 3.2 Templates for Edit Flow - -**Main Template: `templates/booking/edit_v2_cards.html.twig`** - -Similar to create flow, with differences: -- Header shows "Buchung bearbeiten" -- "Reload" button to discard changes -- Different navigation (Zurück to bookings list, Aktualisieren to save) -- Final submit button calls API update - -**Card Template**: Reuse `_participant_card.html.twig` with mode parameter -- Add "storniert" badge for canceled participants -- Disable button for canceled participants - -**Form Template**: Reuse `_participant_form_standalone.html.twig` with mode parameter -- EditFieldStateProvider applies mutability constraints -- Show applicant address placeholders - -**Todos**: -- [ ] Create main template for edit -- [ ] Add reload button with confirmation -- [ ] Modify card template to handle canceled participants -- [ ] Test form template with edit mode -- [ ] Verify mutability constraints applied correctly - ---- - -## Phase 4: Integration & Testing - -### 4.1 Functional Testing Checklist - -**Card View**: -- [ ] Card grid renders all participants correctly -- [ ] Card names show participant names or "Teilnehmer X" fallback -- [ ] Card room names display correctly -- [ ] Card prices formatted as "450,00 €" -- [ ] "Bearbeiten" button on each card -- [ ] Clicking "Bearbeiten" loads participant form -- [ ] Navigation buttons work (Zurück/Weiter or Zurück/Aktualisieren) - -**Participant Form**: -- [ ] Form loads for correct participant -- [ ] All form fields render correctly -- [ ] Field states applied (readonly, disabled, hidden, required) -- [ ] Conditional fields appear/disappear based on data -- [ ] Age eligibility checks work -- [ ] Service fields populated correctly - -**HTMX Interactions**: -- [ ] Field changes trigger refresh endpoint -- [ ] Form refreshes with updated data -- [ ] Sidebar summary updates (OOB swap) -- [ ] Notifications display via toast -- [ ] "Abbrechen" returns to cards without saving -- [ ] "Speichern" submits form with validation -- [ ] Valid submission redirects to cards -- [ ] Invalid submission shows validation errors - -**Field Handlers**: -- [ ] Date of birth enables age-dependent services -- [ ] Room assignment updates counts -- [ ] Ski pass selection filters rentals by duration -- [ ] Rental selection shows insurance checkbox -- [ ] Bulk insurance updates dependent participants -- [ ] Transportation selection shows/hides pickup/parking -- [ ] License plate field appears with parking selection -- [ ] Insurance auto-reassignment on price changes -- [ ] Rental clearing when ski pass changes - -**Data Consistency**: -- [ ] Session DTO updated correctly after form save -- [ ] Session DTO updated correctly after refresh -- [ ] Card data matches DTO after returning from form -- [ ] Prices recalculated correctly -- [ ] Room assignments preserved -- [ ] Service selections preserved - -**Edit Flow Specific**: -- [ ] Booking data loads from API on first visit -- [ ] Session stores data correctly (MODE_EDIT) -- [ ] Canceled participants show badge -- [ ] Canceled participants have disabled button -- [ ] Mutability constraints applied -- [ ] Final submission calls API correctly -- [ ] Reload discards session changes - ---- - -### 4.2 Performance Testing - -**Metrics to Measure**: -- [ ] Card grid load time with 50 participants: < 1 second -- [ ] Participant form load time: < 200ms (lazy loading benefit) -- [ ] Field refresh response time: < 500ms -- [ ] Memory usage with 50 participants: reasonable -- [ ] No N+1 queries in card data generation -- [ ] Session size doesn't grow excessively - -**Performance Tests**: -- [ ] Create booking with 2-3 participants (baseline) -- [ ] Create booking with 10 participants -- [ ] Create booking with 25 participants -- [ ] Create booking with 50 participants -- [ ] Create booking with 100 participants (stress test) -- [ ] Measure database queries per action -- [ ] Measure memory usage per action -- [ ] Profile with Symfony profiler - ---- - -### 4.3 Comparison Testing - -**Compare Old vs New Flow**: -- [ ] Session DTO structure identical -- [ ] Final booking API payload identical -- [ ] Pricing calculations identical -- [ ] Field handler behavior identical -- [ ] Validation rules identical -- [ ] Service selection logic identical -- [ ] Room assignment logic identical -- [ ] Insurance matching logic identical - -**Test Scenarios**: -- [ ] Simple booking (2 adults, no services) -- [ ] Family booking (2 adults, 2 children, services) -- [ ] Complex booking (mixed ages, all services, insurance) -- [ ] Large group (50 participants) -- [ ] Edge cases (age limits, mandatory services, mutability) - ---- - -## Phase 5: Code Sharing Summary - -### Shared Components (100%) - -**Services**: -- `ParticipantCardDataService` (new) -- `BookingService` (existing) -- `BookingPriceCalculatorService` (existing) -- `TravelDataService` (existing) -- `RoomAssignmentService` (existing) -- `ParticipantFieldHandlerRegistry` (existing, extended) -- All field handlers (existing) - -**Form Types**: -- `BookingParticipantType` (existing) -- `AddressType`, `BodyDimensionsType`, etc. (existing) -- All field types (existing) - -**Templates**: -- `_participant_card.html.twig` (new, shared) -- `_participant_form_standalone.html.twig` (new, shared) -- `_summary.html.twig` (existing, shared) - -**Traits**: -- `ParticipantCardFlowTrait` (new, shared) -- `HxTrait` (existing, shared) - -### Controller-Specific (~20%) - -**Create Flow**: -- `CreateStep2RefactoredController` routes -- Initial step validation -- Navigation to step 3 on success -- Template: `create_step_2_v2_cards.html.twig` - -**Edit Flow**: -- `EditRefactoredController` routes -- Initial API data loading -- API update on success -- Canceled participant handling -- Reload functionality -- Template: `edit_v2_cards.html.twig` - ---- - -## Technical Details - -### Form Handling Pattern - -Standard Symfony pattern - single action for both GET and POST: - -```php -public function editParticipant(int $index, Request $request): Response -{ - $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); - $participant = $bookingDto->participants[$index] ?? null; - - if (null === $participant) { - throw new \InvalidArgumentException('Invalid participant index'); - } - - // Create form with booking context passed explicitly - $form = $this->createForm(BookingParticipantType::class, $participant, [ - 'booking_context' => $bookingDto, - 'edit_mode' => false, - 'validation_groups' => ['booking_create_step_2'], - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - // Extract submitted data - $submittedData = $request->request->all(); - $participantData = $submittedData['booking_participant'] ?? []; - - // Process field handlers for this participant - $this->fieldHandlerRegistry->processFieldsForParticipant( - $participantData, - $bookingDto, - $index - ); - - // Save to session - $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); - - // Redirect back to cards - return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2_v2_cards')); - } - - // Render form (GET or invalid POST) - return $this->render('booking/_participant_form_standalone.html.twig', [ - 'form' => $form, - 'participantIndex' => $index, - 'bookingDto' => $bookingDto, - // ... summary data - ]); -} -``` - -### Card Data Structure - -```php -[ - 'name' => 'Max Mustermann', // or "Teilnehmer 1" - 'roomName' => 'Doppelzimmer', - 'price' => '450,00 €' -] -``` - -### HTMX Flow Diagram - -``` -┌─────────────────────────────────────────────┐ -│ CARDS VIEW (GET /participants-v2) │ -│ │ -│ [Teilnehmer 1 | Doppelzimmer | 450,00 €] │ -│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │ -│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │ -│ │ -│ [Zurück] [Weiter] │ -└─────────────────────────────────────────────┘ - │ - │ Click "Bearbeiten" - │ hx-get="/participants-v2/0" - │ hx-target="#main-content" - ▼ -┌─────────────────────────────────────────────┐ -│ FORM VIEW (GET /participants-v2/0) │ -│ │ -│ Teilnehmer 1 │ -│ │ -│ [Vorname] [Nachname] │ -│ [Geburtsdatum] [Geschlecht] │ -│ ... │ -│ [Skipass ▼] ◄────────────┐ │ -│ [Kurse ▼] │ │ -│ │ Field change │ -│ [Abbrechen] [Speichern] │ POST refresh │ -└───────────────────────────┼─────────────────┘ - │ - │ hx-post="/participants-v2/0/refresh" - │ validation_groups: false - │ Returns: form + sidebar OOB swap - ▼ -┌─────────────────────────────────────────────┐ -│ FORM VIEW (refreshed) │ -│ - Form updated with new field states │ -│ - Sidebar summary updated (OOB) │ -│ - Notifications shown via toast │ -└─────────────────────────────────────────────┘ - │ - │ Click "Speichern" - │ POST /participants-v2/0 - │ validation_groups: ['booking_create_step_2'] - ▼ - ┌──────┴──────┐ - │ Validation │ - └──────┬──────┘ - │ - ┌───────┴────────┐ - │ │ - Valid Invalid - │ │ - ▼ ▼ - HX-Redirect Return form - to cards with errors - │ - ▼ -┌─────────────────────────────────────────────┐ -│ CARDS VIEW (all re-rendered) │ -│ │ -│ [Max Mustermann | Doppelzimmer | 580,00 €] │ ← Updated! -│ [Teilnehmer 2 | Einzelzimmer | 520,00 €] │ -│ [Teilnehmer 3 | Doppelzimmer | 450,00 €] │ -└─────────────────────────────────────────────┘ -``` - -### Session Flow - -1. **Cards View**: Load DTO from session, render cards -2. **Form View**: Load DTO from session, create form for participant[index] -3. **Form Refresh**: Load DTO, update with field changes (no validation), save to session -4. **Form Submit**: Load DTO, validate, run handlers, save to session, redirect -5. **Back to Cards**: Load DTO from session (with all changes), render cards - -**Key**: DTO is single source of truth in session, updated incrementally - ---- - -## Implementation Sequence - -### Week 1: Foundation & Create Flow - -**Day 1-2**: Foundation -- [ ] Create `ParticipantCardDataService` with tests -- [ ] Add `processFieldsForParticipant()` to registry with tests -- [ ] Create `ParticipantCardFlowTrait` - -**Day 3**: Templates -- [ ] Create `_participant_card.html.twig` -- [ ] Create `_participant_form_standalone.html.twig` -- [ ] Create `create_step_2_v2_cards.html.twig` - -**Day 4-5**: Create Flow Controller -- [ ] Implement `CreateStep2RefactoredController` -- [ ] All three routes/actions -- [ ] Test with small bookings (2-3 participants) -- [ ] Test field refresh logic -- [ ] Test validation - -### Week 2: Edit Flow & Testing - -**Day 6-7**: Edit Flow -- [ ] Implement `EditRefactoredController` -- [ ] Reuse trait methods -- [ ] Handle canceled participants -- [ ] Create edit templates -- [ ] Test API integration - -**Day 8-9**: Integration Testing -- [ ] Compare outputs (old vs new flows) -- [ ] Test all field handlers -- [ ] Test bulk insurance -- [ ] Test edge cases -- [ ] Performance test with 50+ participants - -**Day 10**: Cleanup & Documentation -- [ ] Code review -- [ ] Update documentation -- [ ] Test both flows side by side -- [ ] Mark as ready for production consideration - ---- - -## Migration Strategy - -### Current State (Before Refactoring) -- Old routes: `/bookings/create/participants` and `/bookings/{id}/edit` -- Old controllers: `CreateStep2Controller`, `EditController` -- Old templates: `create_step_2.html.twig`, `edit.html.twig` - -### Development State (During Refactoring) -- **Old routes remain functional and unchanged** -- New routes: `/bookings/create/participants-v2` and `/bookings/{id}/edit-v2` -- New controllers: `CreateStep2RefactoredController`, `EditRefactoredController` -- New templates: `create_step_2_v2_cards.html.twig`, `edit_v2_cards.html.twig` -- Both flows coexist - **critical for testing and comparison** - -### Transition State (Switching to New Flow) - -**Phase 1: Testing & Validation (During Development)** -- [ ] Deploy both flows to production (both accessible via their routes) -- [ ] Test NEW flow manually in production environment -- [ ] OLD routes remain the default in all navigation -- [ ] NEW routes accessible directly for testing (e.g., via direct URL) -- [ ] Compare bookings created through both flows -- [ ] Verify session DTO structure is identical -- [ ] Confirm pricing calculations match exactly - -**Phase 2: Navigation Switch (Single Deployment)** -- [ ] Update all navigation links to point to NEW routes -- [ ] Update internal redirects to use NEW routes -- [ ] OLD routes remain accessible (direct URL access works) -- [ ] Deploy navigation changes -- [ ] Monitor error rates and booking success rates closely -- [ ] **If issues found**: Deploy rollback (revert navigation to OLD routes) - -**Phase 3: Stabilization (2-4 weeks)** -- [ ] Monitor NEW flow in production with all traffic -- [ ] OLD routes still accessible but deprecated -- [ ] No new features added to OLD flow -- [ ] Document any issues found and resolved - -**Phase 4: Cleanup (After Confidence Established)** -- [ ] Remove OLD controllers (`CreateStep2Controller`, `EditController`) -- [ ] Remove OLD templates (`create_step_2.html.twig`, `edit.html.twig`) -- [ ] Remove OLD routes from routing configuration -- [ ] (Optional) Remove `-v2` suffix from route names and paths - -### Critical Transition Checklist - -**Before Switching Navigation**: -- [ ] All functional tests pass for NEW flow -- [ ] Performance tests show acceptable load times -- [ ] Comparison tests confirm identical DTO structure -- [ ] Field handlers produce identical results -- [ ] Pricing calculations match exactly -- [ ] Session compatibility verified -- [ ] HTMX refresh logic tested extensively -- [ ] Bulk insurance functionality verified -- [ ] All age-dependent service filtering works -- [ ] Transportation service conditional logic works -- [ ] Insurance auto-reassignment tested -- [ ] Edit flow API integration verified -- [ ] Large booking tests (50+ participants) pass - -**Session Compatibility**: -- ✅ Both flows use identical `BookingDto` structure -- ✅ Session keys are mode-specific (`MODE_CREATE`, `MODE_EDIT`) -- ✅ Users can switch between OLD and NEW flows without losing data -- ✅ No session migration needed - -**Rollback Plan**: -If critical issues discovered: -1. **Immediate**: Deploy navigation changes to revert links to OLD routes -2. **Investigation**: Analyze logs, compare booking data, identify root cause -3. **Fix**: Apply fixes to NEW flow controllers/templates -4. **Retest**: Verify fixes in development environment -5. **Redeploy**: Update navigation to NEW routes again - -### Monitoring During Transition - -**Key Metrics to Track**: -- [ ] Booking creation success rate (OLD vs NEW) -- [ ] Average page load time for card grid -- [ ] Average form load time (lazy loading) -- [ ] Field refresh response times -- [ ] Validation error rates -- [ ] Session expiry rates -- [ ] Browser console errors -- [ ] Server error logs -- [ ] Database query counts -- [ ] Memory usage per request - -**Logging Strategy**: -Add structured logging to NEW flow controllers: -```php -$this->logger->info('Refactored flow: Card view loaded', [ - 'participant_count' => count($bookingDto->participants), - 'load_time_ms' => $loadTime, - 'user_id' => $user->getId(), -]); - -$this->logger->info('Refactored flow: Participant form saved', [ - 'participant_index' => $index, - 'had_validation_errors' => !$form->isValid(), - 'field_handler_execution_time_ms' => $handlerTime, -]); -``` - -**Comparison Analysis**: -Since bookings are created via BPN API (not database), compare OLD vs NEW flows by: -- Analyzing application logs for booking creation success/failure rates -- Comparing API response times between OLD and NEW flows -- Monitoring Symfony profiler data for both flows -- Tracking error rates in application logs - -### Post-Transition Cleanup - -After successful transition (2+ months with no issues): - -**Step 1: Remove OLD Controllers and Templates** -- [ ] Delete `CreateStep2Controller.php` -- [ ] Delete `EditController.php` -- [ ] Delete `create_step_2.html.twig` -- [ ] Delete `edit.html.twig` -- [ ] Remove OLD routes from routing configuration - -**Step 2: (Optional) Rename Routes** -- [ ] Rename `app_booking_create_step_2_v2_cards` → `app_booking_create_step_2` -- [ ] Rename `app_booking_create_step_2_v2_participant` → `app_booking_create_step_2_participant` -- [ ] Rename `app_booking_edit_v2_cards` → `app_booking_edit` -- [ ] Update all navigation links and redirects -- [ ] Test thoroughly after rename - -**Step 3: Verify Navigation** -- [ ] Ensure all links point to correct routes (after cleanup) -- [ ] Check for any hardcoded OLD route references -- [ ] Test navigation flow end-to-end - -**Step 4: Documentation Updates** -- [ ] Update developer documentation -- [ ] Remove references to "refactored" or "v2" terminology -- [ ] Update onboarding guides for new developers - -### Common Pitfalls to Avoid - -**❌ DON'T**: -- Switch navigation to NEW flow without thorough testing in production first -- Remove OLD controllers/routes immediately after navigation switch -- Ignore session compatibility between flows -- Switch without monitoring tools in place -- Forget to update ALL navigation links (easy to miss some) - -**✅ DO**: -- Test NEW flow extensively via direct URL access before navigation switch -- Keep OLD flow accessible for weeks/months as safety net -- Monitor metrics closely after navigation switch -- Have rollback plan ready (revert navigation links) -- Document all navigation link locations before switch - ---- - ---- - -## Success Criteria - -### Performance -- [ ] Card grid loads in <1 second with 50 participants -- [ ] Form loads instantly (lazy loading) -- [ ] Field refresh responds in <500ms -- [ ] Memory usage reasonable with large bookings - -### Functionality -- [ ] All field handlers work identically to old flow -- [ ] Validation rules applied correctly -- [ ] Session DTO structure matches old flow -- [ ] Final booking creation/update produces identical results - -### Code Quality -- [ ] 80%+ code reuse between create and edit flows -- [ ] Full test coverage for new services -- [ ] Clean separation of concerns -- [ ] Documented and maintainable - ---- - -## Notes & Considerations - -### Why No OOB Swaps for Cards? -When editing a participant form, the cards view is not rendered - the entire #main-content is replaced with the form. When returning to cards (via "Speichern" or "Abbrechen"), the entire cards view is re-rendered fresh from the session DTO. Therefore, no need for OOB swaps of individual card prices. - -### Why Lazy Loading? -With 50+ participants, rendering all forms at once (current accordion approach) causes: -- Long initial page load time -- High memory usage -- Heavy DOM with hundreds of form fields -Lazy loading loads only the form for the participant being edited, dramatically improving performance. - -### Why Separate Controllers? -Keeps old implementation intact for: -- Comparison testing during development -- Fallback if issues discovered -- Easier code review (clear diff between old and new) -- Lower risk deployment - -### Field Handler Registry Change -Adding `processFieldsForParticipant()` allows processing a single participant's data instead of looping through all participants. This is necessary because the new flow processes participants individually, not all at once. - ---- - -## Open Questions - -- [ ] Should we add a progress indicator showing X/Y participants completed? -- [ ] Should we add keyboard shortcuts (e.g., Ctrl+S to save, Esc to cancel)? -- [ ] Should we add a "Save & Next" button to quickly move through participants? -- [ ] Should we store completion state per participant to show visual progress? - ---- - -## References - -- Current Implementation: `src/Controller/Booking/CreateStep2Controller.php` -- Current Template: `templates/booking/create_step_2.html.twig` -- Field Handlers: `src/Form/Service/` -- HTMX Trait: `src/Htmx/HxTrait.php` diff --git a/docs/PROJECT_OVERVIEW.md b/docs/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..5e1c986 --- /dev/null +++ b/docs/PROJECT_OVERVIEW.md @@ -0,0 +1,332 @@ +# MyEP Next Booking - Project Overview + +**Symfony 6.4 travel booking application** integrating with Bus Pro Net (BPN) XML API. + +## Core Architecture + +### Multi-Step Booking Flow +1. **Step 1**: Room selection and dates (`Create\Step1Controller`) +2. **Step 2**: Participant details with card-based UI (`Create\Step2Controller`) +3. **Step 3**: Payment method selection (`Create\Step3Controller`) +4. **Step 4**: Final confirmation and submission to BPN API (`Create\Step4Controller`) +5. **Edit Flow**: Similar card-based UI for existing bookings (`Edit\IndexController`) + +### Card-Based UI Pattern (Production) +- **Overview**: Grid of participant cards with lazy-loaded individual forms +- **Performance**: Handles 50+ participants efficiently via HTMX +- **Controllers**: + - `Create\Step2Controller` - create flow with validation before step 3 + - `Edit\IndexController` - edit flow with validation before API submission +- **Shared Logic**: + - `ParticipantCardFlowTrait` - Card rendering and form handling + - `ParticipantValidationTrait` - Validation error extraction for card indicators +- **Validation Pattern**: + - Both controllers use validation-only forms (`BookingCreateStep2Type`, `BookingEditType`) + - Standard Symfony form flow: `handleRequest()` → `isSubmitted()` → `isValid()` + - On invalid: Extract error indices, display error banner, highlight cards, disable submit button + - On valid: Proceed to next step (create) or submit to API (edit) + +## Key Architectural Layers + +### BusProNet Integration (`src/BusProNet/`) +- `ApiClient` - XML API communication +- `XmlParser/` - Response parsers (travels, hotels, bookings) +- `XmlLoader/` - Data loaders with caching +- `DataProcessor/` - Transform API data to DTOs + +### Form System (`src/Form/`) +- **DTOs**: `BookingCreateDto`, `ParticipantDto` (session-stored) +- **Field Handlers**: 15+ specialized handlers in `src/Form/Service/` + - Registered via service tags with dependency resolution + - Process in dependency order via `ParticipantFieldHandlerRegistry` +- **Conditional Fields**: Universal condition system (`FieldConditionInterface`) + - Age-based, field-dependent, service-specific conditions + - Applied via `CreateFieldStateProvider` / `EditFieldStateProvider` +- **HTMX Integration**: Real-time updates for dynamic fields + +### Service Layer (`src/Service/`) +- `BookingService` - Core booking workflow +- `BookingPriceCalculatorService` - Real-time pricing +- `BookingFingerprintService` - Dirty state detection for edit mode +- `TravelDataService` - API integration and caching +- `ParticipantCardDataService` - Card display data +- `InsuranceMatchingService` - Insurance eligibility and auto-reassignment +- `RoomAssignmentService` - Automatic room assignment + +## Critical Patterns + +### Field Handler Pattern +```php +// Handlers process in dependency order (topological sort) +// Example: insurance handler depends on ALL price-affecting fields +$this->fieldHandlerRegistry->processFieldsForParticipant( + $participantData, + $bookingDto, + $index +); +``` + +**Important**: Field handlers use "sync pattern" - only sync fields present in original submission to avoid validation errors. + +### HTMX Block-Based Rendering +```php +// All HTMX swaps target #main-content with innerHTML +// OOB swaps for sidebar: #booking-summary +return $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + $templateData +); +``` + +### Service Enrichment Pattern +Services from BPN API may lack complete data (especially prices). Always enrich from travel data: +```php +// BookingDataProcessor::enrichParticipantServicesFromTravel() +// Looks up each service in travel data and replaces with full version +``` + +### Notification System +Field handlers generate notifications (auto-changes) → collected by controller → sent via HX-Trigger → displayed as toasts. + +### Dirty State Detection (Edit Mode) +Fingerprint-based change detection to warn users about unsaved modifications: +- **BookingFingerprintService** generates SHA-256 hash of all mutable booking data +- Original fingerprint stored in `BookingDto::$originalFingerprint` on API load +- `isDirty()` compares current state with original to detect changes +- Yellow warning banner displays when changes detected +- Update button conditionally shown only when dirty +- Fingerprint persists in session, survives page refreshes +- Resets on submission, "Änderungen verwerfen", or "Zurück" actions + +**Unavailable Services Handling:** +- Services with `available <= 0` included in edit mode (not filtered out) +- `ParticipantFieldOptionsProvider::shouldMakeServiceReadonly()` implements intelligent readonly logic: + - **Create mode**: Uses standard availability calculator (filters out unavailable services) + - **Edit mode**: Services participants already have remain editable even if now fully booked + - **Edit mode**: Unavailable services participant doesn't have are marked readonly +- Prevents fingerprint false positives when services become fully booked during editing session +- Ensures service IDs remain consistent in form submissions for accurate dirty detection + +## Key Service Dependencies + +### Field Handler Execution Order +Critical for correct pricing and auto-reassignment: +1. Age-dependent fields (dateOfBirth) +2. Price-affecting services (skiPass, rentals, courses, board, transportation, pickup, parking) +3. **Insurance handler LAST** (depends on all price-affecting fields) +4. Bulk insurance handler (applicant only) + +### Insurance System +- **3-pass parsing**: Referenced IDs → Individual insurances → Packages with family detection +- **Auto-reassignment**: Maintains insurance type when price tier changes +- **Age constraints**: Absolute age (at travel date) vs birth year +- **Hydration**: `TravelDataService::hydrateInsurancePackageRelationships()` rebuilds package relationships after cache deserialization + +### Transportation Services +- **Unified pickup field**: Single field for both directions (BPN API limitation) +- **Conditional visibility**: Pickup vs parking fields mutually exclusive +- **Direction mapping**: `DirectionMapper` translates API ↔ internal codes + +## Important Field Dependencies + +### Ski Pass → Rentals → Insurance → Body Dimensions +- Rentals filtered by ski pass duration (exact date matching) +- Rental insurance only shown when rentals selected +- Body dimensions only shown when rentals selected +- All cleared automatically when dependencies removed + +### Date of Birth → Age-Dependent Services +- Courses, additional services, board, insurance hidden until DOB provided +- Age evaluated at travel start date, not current date +- Dual constraint types: absolute_age, birth_year, mixed + +### Bulk Insurance Booking +- Applicant enables bulk → applies to all participants +- `BulkInsuranceBookingCondition` hides dependent participant insurance fields +- Uses `InsuranceMatchingService::batchAssignInsuranceToParticipants()` for price tier matching + +## Data Flow + +### Create Flow +1. Load/create DTO from session +2. Enrich with fresh API availability data +3. Auto-assign rooms, preselect mandatory services +4. Render cards → user edits participant → field handlers process → save to session +5. Validation check before step 3 +6. Final submission to BPN API + +### Edit Flow +1. Load booking from BPN API on first visit +2. Generate fingerprint of initial state for dirty detection +3. Store in session with `MODE_EDIT` +4. Apply mutability constraints via `EditFieldStateProvider` +5. Same card-based UI as create flow +6. Handle canceled participants (status 'S') +7. Display warning banner when unsaved changes detected +8. **Validation on submission**: Form wraps cards, validates all participants before API call +9. Submit changes back to BPN API (only if validation passes) +10. Staleness warnings after 5 minutes +11. **Session cleanup**: "Zurück" button clears session via `app_booking_edit_cancel` action + +## Common Development Tasks + +### Adding a New Field Handler +1. Create handler class extending `AbstractParticipantFieldHandler` +2. Implement `shouldProcess()`, `process()`, `getDependencies()` +3. Register in `services.yaml` with `participant.field_handler` tag +4. Add field options to `ParticipantFieldOptionsProvider` +5. Add conditional logic to `CreateFieldStateProvider` if needed +6. Update template with HTMX refresh triggers + +### Adding a New Conditional Field +1. Create condition class implementing `FieldConditionInterface` +2. Register in `CreateFieldStateProvider::registerFieldStateConditions()` +3. Use composite conditions for complex logic (AND/OR/NOT) + +### Debugging Field Handler Issues +- Check execution order in `ParticipantFieldHandlerRegistry` (topological sort) +- Verify `shouldProcess()` logic for mode awareness +- Ensure dependencies declared correctly +- Check sync pattern: only sync fields in original submission + +## File Locations + +### Key Design Patterns + +**Service Availability in Edit Mode:** +Edit mode requires special handling of unavailable services to prevent fingerprint false positives: +```php +// ParticipantFieldOptionsProvider - intelligent availability filtering +$bookingDto->travel->getAdditionalServicesBySubTypes( + Constants::TOKEN_COURSES, + BookingDto::MODE_CREATE === $bookingDto->getMode() // Only filter in create mode +) + +// shouldMakeServiceReadonly() - context-aware readonly logic +// In edit mode: service readonly ONLY if unavailable AND participant doesn't have it +private function shouldMakeServiceReadonly(Service $service, BookingDto $bookingDto, int $participantIndex, string $fieldName): bool +{ + if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { + return $this->isServiceUnavailableForParticipant($service, $bookingDto, $participantIndex); + } + + // Edit mode: allow keeping services participant already has + if (null !== $service->available && $service->available > 0) { + return false; + } + + $participantHasService = match ($fieldName) { + 'courses' => $this->hasServiceById($participant->courses, $service->id), + 'additionalServices' => $this->hasServiceById($participant->additionalServices, $service->id), + // ... other field types + }; + + return !$participantHasService; // Readonly only if participant doesn't have it +} +``` + +**Session Lifecycle Management:** +Edit mode session requires proper cleanup to prevent dirty state persistence: +- **Entry**: `IndexController::loadFormData()` initializes from API with original fingerprint +- **Exit (save)**: `IndexController::index()` clears session on successful API update +- **Exit (discard)**: `IndexController::reloadFromApi()` clears session and reloads from API +- **Exit (cancel)**: `IndexController::cancelEdit()` clears session when user clicks "Zurück" +- **Validation**: Dirty state persists across page refreshes until explicit action taken + +### Controllers +**Create Namespace** (`src/Controller/Booking/Create/`): +- `IndexController.php` - Booking session initialization and error handling +- `Step1Controller.php` - Room selection and dates +- `Step2Controller.php` - Participant details with card-based UI +- `Step3Controller.php` - Payment method selection +- `Step4Controller.php` - Final confirmation and API submission +- `SuccessController.php` - Success page after booking completion + +**Edit Namespace** (`src/Controller/Booking/Edit/`): +- `IndexController.php` - Edit flow with card-based UI, validation, and session management + - `index()` - Main edit view with dirty state detection + - `editParticipant()` - Individual participant form editing + - `refreshParticipantForm()` - HTMX refresh without validation + - `reloadFromApi()` - Discard changes and reload from API + - `cancelEdit()` - Clean session exit to bookings list + +**Root Booking Namespace** (`src/Controller/Booking/`): +- `IndexController.php` - Bookings list +- `DownloadController.php` - Booking document downloads + +**Shared Traits** (`src/Controller/Booking/Traits/`): +- `ParticipantCardFlowTrait` - Card rendering, form creation, summary calculation +- `ParticipantValidationTrait` - Validation error extraction for card indicators +- `BookingCreateTrait` - Create flow helpers +- `BookingDataTrait` - API data fetching +- `BookingExceptionHandlerTrait` - Error handling + +### Templates +**Create Flow**: +- `templates/booking/create/step_1.html.twig` - Room selection +- `templates/booking/create/step_2.html.twig` - Participant cards +- `templates/booking/create/step_3.html.twig` - Payment method +- `templates/booking/create/step_4.html.twig` - Confirmation +- `templates/booking/create/success.html.twig` - Success page +- `templates/booking/create/error.html.twig` - Error page + +**Edit Flow**: +- `templates/booking/edit/index.html.twig` - Edit with participant cards + +**Shared Components**: +- `templates/booking/_participant_card.html.twig` - Individual participant card +- `templates/booking/_participant_form.html.twig` - Participant edit form +- `templates/booking/_summary.html.twig` - Pricing summary sidebar + +### Services +- `src/Service/BookingService.php` - Core workflow and session management +- `src/Service/BookingFingerprintService.php` - Dirty state detection via SHA-256 fingerprinting +- `src/Service/ParticipantCardDataService.php` - Card data generation +- `src/Form/Service/ParticipantFieldHandlerRegistry.php` - Handler orchestration +- `src/Form/Service/ParticipantFieldOptionsProvider.php` - Field configuration with mode-aware availability +- `src/Form/Service/CreateFieldStateProvider.php` - Conditional field states +- `src/Form/Service/EditFieldStateProvider.php` - Edit mode mutability constraints + +### Field Handlers +- `src/Form/Service/Participant*FieldHandler.php` (15+ handlers) +- Transportation, pickup, parking, ski pass, rentals, insurance, bulk insurance, etc. + +## Testing + +```bash +./vendor/bin/phpunit # All tests +./vendor/bin/phpunit tests/Service/ # Service layer +./vendor/bin/phpunit tests/BusProNet/ # API integration +./vendor/bin/php-cs-fixer fix # Code style (Symfony ruleset) +``` + +## Development Environment + +```bash +ddev start # Start DDEV +ddev composer install # Install dependencies +ddev exec bin/console cache:clear # Clear cache +ddev logs # Read PHP error logs +ddev exec "php -r 'opcache_reset()';" # Clear opcache after code changes +``` + +## Important Notes + +- **Room prices are per person** +- **Zero prices display without suffix** (e.g., "Vollpension" not "Vollpension (€0,00)") +- **All services sorted by price** (cheapest first) via `SortByPriceTrait` +- **Field sync pattern critical**: Only sync fields in original submission to avoid "extra fields" errors +- **Insurance handler requires mode awareness**: Skips processing in edit mode (API doesn't return insurance data) +- **Clear opcache after code changes** affecting hydration or serialization +- **HTMX targeting consistency**: All swaps target `#main-content` with `innerHTML`, sidebar via OOB swap +- **Validation pattern**: Both create and edit flows use validation-only forms that wrap card UI for standard Symfony form handling +- **Card error indicators**: `ParticipantValidationTrait::extractParticipantErrorIndices()` parses form errors to highlight invalid participant cards +- **Edit mode service availability**: Services with `available <= 0` remain visible and editable for participants who already have them (prevents fingerprint false positives) +- **Session cleanup on exit**: All exit paths from edit mode (save, discard, cancel) properly clear session to reset dirty state + +## References + +- **Project conventions**: `../CLAUDE.md` (root level) +- **User preferences**: `~/.claude/CLAUDE.md` +- **Documentation index**: `README.md` (this directory) diff --git a/README.md b/docs/README.md similarity index 95% rename from README.md rename to docs/README.md index b32e0fe..719c603 100644 --- a/README.md +++ b/docs/README.md @@ -258,12 +258,8 @@ $grandTotal = $serviceTotal + $roomTotal; ## 📚 Documentation ### Available Documentation -- **[CLAUDE.md](CLAUDE.md)**: Development guidelines for AI assistance -- **[Field State System](docs/FIELD_STATE_SYSTEM.md)**: Conditional field architecture -- **[Form Processing](docs/FORM_PROCESSING.md)**: Form handler system details -- **[Pricing Implementation](docs/PRICING_DISPLAY_IMPLEMENTATION.md)**: Pricing system documentation -- **[Transportation Services](docs/TRANSPORTATION_SERVICES_IMPLEMENTATION_PLAN.md)**: Transportation feature details -- **[Age-Based Fields](docs/AGE_BASED_FIELDS_PLAN.md)**: Age constraint system +- **[PROJECT_OVERVIEW.md](PROJECT_OVERVIEW.md)**: Comprehensive architecture and implementation guide +- **[CLAUDE.md](../CLAUDE.md)**: Development guidelines for AI assistance (root level) ### Architecture Documentation Each major system component has detailed documentation covering: diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index a8fdc4a..5e2a6b5 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -110,12 +110,85 @@ class BookingDataProcessor $room = $booking->getRoomForParticipant($index); $participantData->assignedRoomId = $room?->id; + // Enrich services with data from travel (especially prices) + $this->enrichParticipantServicesFromTravel($participantData, $travel); + $dto->participants[$index] = $participantData; } return $dto; } + /** + * Enriches participant service selections with data from travel model. + * + * Services extracted from booking API responses might not include all necessary data + * (especially prices). This method looks up each service in the travel data and copies + * over missing properties to ensure proper pricing calculations. + * + * @param ParticipantDto $participant The participant with service selections + * @param Travel $travel The travel data containing full service information + */ + private function enrichParticipantServicesFromTravel(ParticipantDto $participant, Travel $travel): void + { + // Enrich courses + foreach ($participant->courses as $key => $course) { + if (isset($travel->additionalServices[$course->id])) { + $participant->courses[$key] = $travel->additionalServices[$course->id]; + } + } + + // Enrich ski pass + if (null !== $participant->skiPass && isset($travel->additionalServices[$participant->skiPass->id])) { + $participant->skiPass = $travel->additionalServices[$participant->skiPass->id]; + } + + // Enrich additional services + foreach ($participant->additionalServices as $key => $service) { + if (isset($travel->additionalServices[$service->id])) { + $participant->additionalServices[$key] = $travel->additionalServices[$service->id]; + } + } + + // Enrich board + foreach ($participant->board as $key => $board) { + if (isset($travel->additionalServices[$board->id])) { + $participant->board[$key] = $travel->additionalServices[$board->id]; + } + } + + // Enrich rentals + foreach ($participant->rentals as $key => $rental) { + if (isset($travel->additionalServices[$rental->id])) { + $participant->rentals[$key] = $travel->additionalServices[$rental->id]; + } + } + + // Enrich rental insurance + if (null !== $participant->rentalInsurance && isset($travel->additionalServices[$participant->rentalInsurance->id])) { + $participant->rentalInsurance = $travel->additionalServices[$participant->rentalInsurance->id]; + } + + // Enrich transportation services + if (null !== $participant->transportationOutbound && isset($travel->transportationServices[$participant->transportationOutbound->id])) { + $participant->transportationOutbound = $travel->transportationServices[$participant->transportationOutbound->id]; + } + + if (null !== $participant->transportationInbound && isset($travel->transportationServices[$participant->transportationInbound->id])) { + $participant->transportationInbound = $travel->transportationServices[$participant->transportationInbound->id]; + } + + // Enrich pickup + if (null !== $participant->pickup && isset($travel->pickupsOutbound[$participant->pickup->id])) { + $participant->pickup = $travel->pickupsOutbound[$participant->pickup->id]; + } + + // Enrich insurance + if (null !== $participant->insurance && isset($travel->insurances[$participant->insurance->id])) { + $participant->insurance = $travel->insurances[$participant->insurance->id]; + } + } + /** * Creates an update request payload for the BusProNet API from booking form data. * diff --git a/src/Controller/Booking/CreateInitController.php b/src/Controller/Booking/Create/IndexController.php similarity index 92% rename from src/Controller/Booking/CreateInitController.php rename to src/Controller/Booking/Create/IndexController.php index 2163391..cdbafbe 100644 --- a/src/Controller/Booking/CreateInitController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Create; use App\BusProNet\XmlLoader\AgencyLoader; use App\Exception\BookingNotPossibleException; @@ -23,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route; * without requiring random UID parameters. It creates fresh booking sessions * and redirects to the first step of the booking process. */ -class CreateInitController extends AbstractController +class IndexController extends AbstractController { public function __construct( private readonly BookingService $bookingService, @@ -42,7 +42,11 @@ class CreateInitController extends AbstractController * corresponding agency ID is stored in the booking. If not provided or invalid, * defaults to agency code '0001'. */ - #[Route('/bookings/create/{dateId}/{hotelId}', name: 'app_booking_create_init', requirements: ['dateId' => '\d+', 'hotelId' => '\d+'])] + #[Route( + path: '/bookings/create/{dateId}/{hotelId}', + name: 'app_booking_create_init', + requirements: ['dateId' => '\d+', 'hotelId' => '\d+'] + )] public function init(Request $request, int $dateId, int $hotelId): Response { try { @@ -110,6 +114,6 @@ class CreateInitController extends AbstractController #[Route('/bookings/create/error', name: 'app_booking_create_error')] public function error(Request $request): Response { - return $this->render('booking/create_error.html.twig'); + return $this->render('booking/create/error.html.twig'); } } diff --git a/src/Controller/Booking/CreateStep1Controller.php b/src/Controller/Booking/Create/Step1Controller.php similarity index 94% rename from src/Controller/Booking/CreateStep1Controller.php rename to src/Controller/Booking/Create/Step1Controller.php index cd3c302..9745959 100644 --- a/src/Controller/Booking/CreateStep1Controller.php +++ b/src/Controller/Booking/Create/Step1Controller.php @@ -2,8 +2,10 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Create; +use App\Controller\Booking\Traits\BookingCreateTrait; +use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep1Type; use App\Htmx\HxTrait; use App\Service\BookingService; @@ -18,7 +20,7 @@ use Symfony\Component\Routing\Attribute\Route; * This controller manages room selection functionality where users * choose the types and quantities of rooms for their booking. */ -class CreateStep1Controller extends AbstractController +class Step1Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; @@ -81,7 +83,7 @@ class CreateStep1Controller extends AbstractController $groupedRooms = $this->bookingService->groupRoomsBySelectionType($availableRooms); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($summary['selectedRooms'], $availableRooms); - return $this->render('booking/create_step_1.html.twig', [ + return $this->render('booking/create/step_1.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'roomSummary' => $summary['selectedRooms'], 'participantCount' => $summary['participantCount'], @@ -121,7 +123,7 @@ class CreateStep1Controller extends AbstractController // The DTO is now updated with the latest selection. // We can now render the blocks with the fresh data. return $this->htmxOobResponse( - 'booking/create_step_1.html.twig', + 'booking/create/step_1.html.twig', ['room_selection_form', 'booking_summary'], [ 'form' => $form->createView(), diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php new file mode 100644 index 0000000..bba9057 --- /dev/null +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -0,0 +1,269 @@ +getOrCreateBookingCreateDto($this->bookingService, $request); + if ($result instanceof Response) { + return $result; + } + $bookingCreateDto = $result; + + // Enrich with fresh availability data + $this->enrichWithFreshAvailabilities($bookingCreateDto); + + // Validate step access + if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) { + return $redirect; + } + + // Ensure correct number of participants + $this->ensureCorrectNumberOfParticipants($bookingCreateDto); + + // Auto-assign rooms if needed + $this->autoAssignRoomsIfNeeded($bookingCreateDto); + + // Preselect mandatory services + $this->bookingService->preselectMandatoryServices($bookingCreateDto); + + // Save BookingDto to session + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + + // Create validation form + $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ + 'validation_groups' => ['booking_create_step_2'], + ]); + $form->handleRequest($request); + + // Handle form submission (clicking "Weiter") + if ($form->isSubmitted() && $form->isValid()) { + // All participants validated successfully, update current step + $bookingCreateDto->currentStep = 3; + $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); + + // Proceed to Step 3 + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); + } + + // Extract participant indices with validation errors + $participantErrors = []; + if ($form->isSubmitted() && false === $form->isValid()) { + $participantErrors = $this->extractParticipantErrorIndices($form); + } + + // Generate cards data + $cardsData = $this->generateAllCardsData($bookingCreateDto); + + // Calculate summary data + $summaryData = $this->calculateSummaryData($bookingCreateDto); + + // Get detailed pricing data for summary sidebar + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); + + $templateData = [ + 'form' => $form->createView(), + 'bookingDto' => $bookingCreateDto, + 'cardsData' => $cardsData, + 'summaryData' => $summaryData, + 'pricingData' => $summary['pricing'], + 'participantErrors' => $participantErrors, + ]; + + // If HTMX request, render only blocks to avoid layout duplication + if ($this->isHxRequest($request)) { + return $this->htmxOobResponse( + 'booking/create/step_2.html.twig', + ['participant_cards', 'booking_summary'], + $templateData + ); + } + + // Regular request: render full template + return $this->render('booking/create/step_2.html.twig', $templateData); + } + + /** + * Show or submit individual participant form. + */ + #[Route( + path: '/bookings/create/participants/{index}', + name: 'app_booking_create_step_2_participant', + requirements: ['index' => '\d+'] + )] + public function editParticipant(int $index, Request $request): Response + { + $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); + + // Validate participant index + if (false === isset($bookingDto->participants[$index])) { + throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); + } + + // Create form with booking_context option + $form = $this->createParticipantForm($bookingDto, $index, [ + 'validation_groups' => ['booking_create_step_2'], + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // Save BookingDto to session + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); + + // HTMX redirect to cards view + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2')); + } + + // Calculate summary data for sidebar + $summaryData = $this->calculateSummaryData($bookingDto); + + // Get detailed pricing data for summary sidebar + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); + + // Render form and sidebar with OOB swap using htmxOobResponse + // This ensures both initial load and refresh use the same block-based rendering + return $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'summaryData' => $summaryData, + 'pricingData' => $summary['pricing'], + 'refreshRouteName' => 'app_booking_create_step_2_participant_refresh', + 'submitRouteName' => 'app_booking_create_step_2_participant', + ] + ); + } + + /** + * HTMX refresh endpoint for individual participant form. + */ + #[Route( + path: '/bookings/create/participants/{index}/refresh', + name: 'app_booking_create_step_2_participant_refresh', + requirements: ['index' => '\d+'], + methods: ['POST'] + )] + public function refreshParticipantForm(int $index, Request $request): Response + { + $bookingDto = $this->loadBookingDtoOrFail($request, BookingDto::MODE_CREATE); + + // Enrich with fresh availability data + $this->enrichWithFreshAvailabilities($bookingDto); + + // Validate participant index + if (false === isset($bookingDto->participants[$index])) { + throw $this->createNotFoundException(sprintf('Participant at index %d does not exist', $index)); + } + + // Use trait method for refresh handling + return $this->handleParticipantRefresh( + $request, + $bookingDto, + $index, + 'app_booking_create_step_2_participant_refresh', + 'app_booking_create_step_2_participant' + ); + } + + /** + * Ensures the booking DTO has the correct number of participant objects. + */ + private function ensureCorrectNumberOfParticipants(BookingDto $bookingCreateDto): void + { + $participantsCount = $this->getParticipantsCount($bookingCreateDto); + + $participants = $bookingCreateDto->participants; + $bookingCreateDto->participants = []; + for ($i = 0; $i < $participantsCount; ++$i) { + $participant = $participants[$i] ?? new ParticipantDto(); + $participant->index = $i; + $bookingCreateDto->participants[$i] = $participant; + } + } + + /** + * Enriches travel data with cached availability information from BusProNet API. + */ + private function enrichWithFreshAvailabilities(BookingDto $bookingCreateDto): void + { + $dateId = $bookingCreateDto->travel->id; + + $availabilities = $this->travelDataService->getAvailabilityData($dateId, true); + + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($bookingCreateDto->travel, $availabilities); + } + } + + /** + * Automatically assigns participants to rooms if they don't have room assignments yet. + */ + private function autoAssignRoomsIfNeeded(BookingDto $bookingCreateDto): void + { + // Check if any participants need room assignment + $needsAssignment = false; + foreach ($bookingCreateDto->participants as $participant) { + if (null === $participant->assignedRoomId) { + $needsAssignment = true; + break; + } + } + + if ($needsAssignment) { + $this->roomAssignmentService->assignParticipantsToRooms($bookingCreateDto); + } + } + +} diff --git a/src/Controller/Booking/CreateStep3Controller.php b/src/Controller/Booking/Create/Step3Controller.php similarity index 56% rename from src/Controller/Booking/CreateStep3Controller.php rename to src/Controller/Booking/Create/Step3Controller.php index 3db0dcd..05f7251 100644 --- a/src/Controller/Booking/CreateStep3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -2,16 +2,20 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Create; use App\BusProNet\ApiClient; use App\BusProNet\Model\Notification; +use App\Controller\Booking\Traits\BookingCreateTrait; +use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep3Type; +use App\Form\Model\BookingDto; use App\Htmx\HxTrait; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -19,7 +23,7 @@ use Symfony\Component\Routing\Attribute\Route; /** * Handles the third step of the booking creation process (payment method selection). */ -class CreateStep3Controller extends AbstractController +class Step3Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; @@ -37,7 +41,7 @@ class CreateStep3Controller extends AbstractController * Displays and processes the payment method form. */ #[Route('/bookings/create/payment', name: 'app_booking_create_step_3')] - public function step3(Request $request): Response + public function index(Request $request): Response { $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); if ($result instanceof Response) { @@ -66,29 +70,23 @@ class CreateStep3Controller extends AbstractController $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); if ($inquiryResponse instanceof Notification) { - $this->logger->error('Booking inquiry failed', [ - 'message' => $inquiryResponse->message, - ]); - $this->addFlash('error', 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.'); - - return $this->render('booking/create_step_3.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleInquiryError( + 'Booking inquiry failed', + ['message' => $inquiryResponse->message], + 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + $bookingCreateDto, + $form + ); } if (false === $inquiryResponse->isInquiryValid()) { - $this->logger->error('Booking inquiry validation failed', [ - 'status' => $inquiryResponse->status, - ]); - $this->addFlash('error', 'Buchung konnte nicht validiert werden.'); - - return $this->render('booking/create_step_3.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleInquiryError( + 'Booking inquiry validation failed', + ['status' => $inquiryResponse->status], + 'Buchung konnte nicht validiert werden.', + $bookingCreateDto, + $form + ); } // Validate price match (exact comparison) @@ -96,18 +94,17 @@ class CreateStep3Controller extends AbstractController $calculatedTotal = $this->priceCalculator->calculateGrandTotal($bookingCreateDto); if ($apiTotal !== $calculatedTotal) { - $this->logger->error('Price mismatch detected - payload incomplete', [ - 'apiTotal' => $apiTotal, - 'calculatedTotal' => $calculatedTotal, - 'difference' => abs($apiTotal - $calculatedTotal), - ]); - $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); - - return $this->render('booking/create_step_3.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleInquiryError( + 'Price mismatch detected - payload incomplete', + [ + 'apiTotal' => $apiTotal, + 'calculatedTotal' => $calculatedTotal, + 'difference' => abs($apiTotal - $calculatedTotal), + ], + 'Ein technischer Fehler ist aufgetreten.', + $bookingCreateDto, + $form + ); } // Validation successful - proceed to confirmation step @@ -116,32 +113,26 @@ class CreateStep3Controller extends AbstractController return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_4')); } catch (\Exception $e) { - $this->logger->error('Booking inquiry exception', [ - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); - - return $this->render('booking/create_step_3.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleInquiryError( + 'Booking inquiry exception', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ], + 'Ein technischer Fehler ist aufgetreten.', + $bookingCreateDto, + $form + ); } } - return $this->render('booking/create_step_3.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->renderStepForm($bookingCreateDto, $form); } /** * Handles HTMX refresh when payment method changes. */ - #[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh')] + #[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh', methods: ['POST'])] public function refresh(Request $request): Response { $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); @@ -157,7 +148,31 @@ class CreateStep3Controller extends AbstractController $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); - return $this->render('booking/create_step_3.html.twig', [ + return $this->renderStepForm($bookingCreateDto, $form); + } + + /** + * Handles inquiry errors by logging, adding flash message, and rendering the form. + */ + private function handleInquiryError( + string $logMessage, + array $context, + string $flashMessage, + BookingDto $bookingCreateDto, + FormInterface $form, + ): Response { + $this->logger->error($logMessage, $context); + $this->addFlash('error', $flashMessage); + + return $this->renderStepForm($bookingCreateDto, $form); + } + + /** + * Renders the step 3 form with standard template variables. + */ + private function renderStepForm(BookingDto $bookingCreateDto, FormInterface $form): Response + { + return $this->render('booking/create/step_3.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'form' => $form->createView(), ...$this->getSummaryVariables($bookingCreateDto), diff --git a/src/Controller/Booking/CreateStep4Controller.php b/src/Controller/Booking/Create/Step4Controller.php similarity index 54% rename from src/Controller/Booking/CreateStep4Controller.php rename to src/Controller/Booking/Create/Step4Controller.php index 2e2b726..61dd19b 100644 --- a/src/Controller/Booking/CreateStep4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -2,15 +2,19 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Create; use App\BusProNet\ApiClient; use App\BusProNet\Model\Notification; +use App\Controller\Booking\Traits\BookingCreateTrait; +use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep4Type; +use App\Form\Model\BookingDto; use App\Htmx\HxTrait; use App\Service\BookingService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -18,7 +22,7 @@ use Symfony\Component\Routing\Attribute\Route; /** * Handles the fourth step of the booking creation process (confirmation). */ -class CreateStep4Controller extends AbstractController +class Step4Controller extends AbstractController { use BookingCreateTrait; use BookingExceptionHandlerTrait; @@ -35,7 +39,7 @@ class CreateStep4Controller extends AbstractController * Displays booking summary and confirmation form. */ #[Route('/bookings/create/confirmation', name: 'app_booking_create_step_4')] - public function step4(Request $request): Response + public function index(Request $request): Response { $result = $this->getOrCreateBookingCreateDto($this->bookingService, $request); if ($result instanceof Response) { @@ -64,47 +68,69 @@ class CreateStep4Controller extends AbstractController $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); if ($bookingResponse instanceof Notification) { - $this->addFlash('error', $bookingResponse->message); - - return $this->render('booking/create_step_4.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleBookingError( + 'Booking creation failed - API notification', + ['message' => $bookingResponse->message], + $bookingResponse->message, + $bookingCreateDto, + $form + ); } if (false === $bookingResponse->isBookingSuccessful()) { - $this->addFlash('error', 'Buchung konnte nicht erstellt werden.'); - - return $this->render('booking/create_step_4.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleBookingError( + 'Booking creation unsuccessful', + ['status' => $bookingResponse->status], + 'Buchung konnte nicht erstellt werden.', + $bookingCreateDto, + $form + ); } // Success: Store booking number in flash and clear session $this->addFlash('booking_number', $bookingResponse->transactionNumber); $this->bookingService->clearBookingCreateDto($request); - return $this->hxRedirect($request, $this->generateUrl('app_booking_success')); + return $this->hxRedirect($request, $this->generateUrl('app_booking_create_success')); } catch (\Exception $e) { - $this->logger->error('Booking creation failed', [ - 'exception' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - - $this->addFlash('error', 'Ein technischer Fehler ist aufgetreten.'); - - return $this->render('booking/create_step_4.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'form' => $form->createView(), - ...$this->getSummaryVariables($bookingCreateDto), - ]); + return $this->handleBookingError( + 'Booking creation exception', + [ + 'exception' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ], + 'Ein technischer Fehler ist aufgetreten.', + $bookingCreateDto, + $form + ); } } - return $this->render('booking/create_step_4.html.twig', [ + return $this->renderStepForm($bookingCreateDto, $form); + } + + /** + * Handles booking errors by logging, adding flash message, and rendering the form. + */ + private function handleBookingError( + string $logMessage, + array $context, + string $flashMessage, + BookingDto $bookingCreateDto, + FormInterface $form, + ): Response { + $this->logger->error($logMessage, $context); + $this->addFlash('error', $flashMessage); + + return $this->renderStepForm($bookingCreateDto, $form); + } + + /** + * Renders the step 4 form with standard template variables. + */ + private function renderStepForm(BookingDto $bookingCreateDto, FormInterface $form): Response + { + return $this->render('booking/create/step_4.html.twig', [ 'bookingCreateDto' => $bookingCreateDto, 'form' => $form->createView(), ...$this->getSummaryVariables($bookingCreateDto), diff --git a/src/Controller/Booking/BookingSuccessController.php b/src/Controller/Booking/Create/SuccessController.php similarity index 67% rename from src/Controller/Booking/BookingSuccessController.php rename to src/Controller/Booking/Create/SuccessController.php index f263a0d..f9ce6b5 100644 --- a/src/Controller/Booking/BookingSuccessController.php +++ b/src/Controller/Booking/Create/SuccessController.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Create; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -class BookingSuccessController extends AbstractController +/** + * Handles the success page after completing the booking creation flow. + */ +class SuccessController extends AbstractController { - #[Route('/bookings/success', name: 'app_booking_success')] + #[Route('/bookings/create/success', name: 'app_booking_create_success')] public function success(Request $request): Response { $bookingNumber = $request->getSession()->getFlashBag()->get('booking_number')[0] ?? null; @@ -21,7 +24,7 @@ class BookingSuccessController extends AbstractController return $this->redirect('https://www.ep-reisen.de'); } - return $this->render('booking/success.html.twig', [ + return $this->render('booking/create/success.html.twig', [ 'bookingNumber' => $bookingNumber, ]); } diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php deleted file mode 100644 index 7df9094..0000000 --- a/src/Controller/Booking/CreateStep2Controller.php +++ /dev/null @@ -1,264 +0,0 @@ -getOrCreateBookingCreateDto($this->bookingService, $request); - if ($result instanceof Response) { - return $result; - } - $bookingCreateDto = $result; - - // Enrich with fresh availability data - $this->enrichWithFreshAvailabilities($bookingCreateDto); - - // Validate step access - if ($redirect = $this->validateStepAccess($bookingCreateDto, 2)) { - return $redirect; - } - - // Ensure correct number of participants - $this->ensureCorrectNumberOfParticipants($bookingCreateDto); - $participantsCount = $this->getParticipantsCount($bookingCreateDto); - - // Auto-assign participants to rooms if not already assigned - $this->autoAssignRoomsIfNeeded($bookingCreateDto); - - // Pre-select mandatory services for participants with birth dates - $this->bookingService->preselectMandatoryServices($bookingCreateDto); - - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - - $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'attr' => [ - 'novalidate' => 'novalidate', - 'hx-post' => $this->generateUrl('app_booking_create_step_2'), - 'hx-target' => '#form-wrapper', - 'hx-select' => '#form-wrapper', - 'hx-swap' => 'outerHTML', - ], - 'validation_groups' => ['booking_create_step_2'], - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - $bookingCreateDto->currentStep = 3; - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - - return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_3')); - } - - $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); - $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); - $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms); - $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto); - - return $this->render('booking/create_step_2.html.twig', [ - 'bookingCreateDto' => $bookingCreateDto, - 'participantsCount' => $participantsCount, - 'pricingData' => $summary['pricing'], - 'assignmentCounts' => $roomAssignmentCounts, - 'participantPrices' => $participantPrices, - 'form' => $form->createView(), - 'groupedSelectedRooms' => $groupedSelectedRooms, - ]); - } - - /** - * Handles HTMX requests for dynamic form updates when room selections change by submitting the form - * without validation and returning a freshly rendered instance. - */ - #[Route('/bookings/create/participants/refresh', name: 'app_booking_create_step_2_refresh', methods: ['POST'])] - public function refreshParticipantForm(Request $request): Response - { - $result = $this->getOrCreateBookingCreateDtoForHtmx($this->bookingService, $request); - if ($result instanceof Response) { - return $result; - } - $bookingCreateDto = $result; - - // Enrich with fresh availability data - $this->enrichWithFreshAvailabilities($bookingCreateDto); - - $participantsCount = $this->getParticipantsCount($bookingCreateDto); - - // Process form data without validation to capture current state - $form = $this->createForm(BookingCreateStep2Type::class, $bookingCreateDto, [ - 'attr' => ['novalidate' => 'novalidate'], - 'validation_groups' => false, - ]); - - $form->handleRequest($request); - - // Pre-select mandatory services after form processing but before pricing calculation - $this->bookingService->preselectMandatoryServices($bookingCreateDto); - - $this->bookingService->saveBookingDto($request, $bookingCreateDto, BookingDto::MODE_CREATE); - - // Collect notifications from all participants - $notifications = $this->collectParticipantNotifications($bookingCreateDto); - - $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); - $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); - $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms); - $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto); - - // The DTO is now updated with the latest selection and submitted data has been cleaned. - // We can now render the blocks with the fresh data. - $response = $this->htmxOobResponse( - 'booking/create_step_2.html.twig', - ['participants_form', 'booking_summary'], - [ - 'form' => $form->createView(), - 'bookingCreateDto' => $bookingCreateDto, - 'participantsCount' => $participantsCount, - 'pricingData' => $summary['pricing'], - 'assignmentCounts' => $roomAssignmentCounts, - 'participantPrices' => $participantPrices, - 'groupedSelectedRooms' => $groupedSelectedRooms, - ] - ); - - // Add notifications to HTMX trigger header if any exist - if ([] !== $notifications) { - $response->headers->set('HX-Trigger', json_encode([ - 'showNotifications' => ['notifications' => $notifications], - ])); - } - - return $response; - } - - /** - * Ensures the booking DTO has the correct number of participant objects. - * - * Creates or reuses participant DTOs to match the required participant count - * based on room selections. Preserves existing participant data when possible - * and assigns proper index values. - * - * @param BookingDto $bookingCreateDto The booking DTO to update - */ - private function ensureCorrectNumberOfParticipants(BookingDto $bookingCreateDto): void - { - $participantsCount = $this->getParticipantsCount($bookingCreateDto); - - $participants = $bookingCreateDto->participants; - $bookingCreateDto->participants = []; - for ($i = 0; $i < $participantsCount; ++$i) { - $participant = $participants[$i] ?? new ParticipantDto(); - $participant->index = $i; - $bookingCreateDto->participants[$i] = $participant; - } - } - - /** - * Enriches travel data with cached availability information from BusProNet API. - * - * Fetches availability data with short-term caching and patches the travel object - * to ensure service availability is reasonably up-to-date while reducing API calls. - * This is essential for accurate pricing and service selection during the booking process. - * - * @param BookingDto $bookingCreateDto The booking DTO containing travel data to enrich - */ - private function enrichWithFreshAvailabilities(BookingDto $bookingCreateDto): void - { - $dateId = $bookingCreateDto->travel->id; - - $availabilities = $this->travelDataService->getAvailabilityData($dateId, true); - - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($bookingCreateDto->travel, $availabilities); - } - } - - /** - * Automatically assigns participants to rooms if they don't have room assignments yet. - * - * This is called when entering Step 2 to ensure all participants have room assignments - * based on the selected rooms from Step 1. Only assigns if participants are unassigned. - * - * @param BookingDto $bookingCreateDto The booking DTO with participants and room selections - */ - private function autoAssignRoomsIfNeeded(BookingDto $bookingCreateDto): void - { - // Check if any participants need room assignment - $needsAssignment = false; - foreach ($bookingCreateDto->participants as $participant) { - if (null === $participant->assignedRoomId) { - $needsAssignment = true; - break; - } - } - - if ($needsAssignment) { - $this->roomAssignmentService->assignParticipantsToRooms($bookingCreateDto); - } - } - - /** - * Collects all notifications from participants and clears them. - * - * @param BookingDto $bookingCreateDto The booking DTO containing participants - * - * @return array Array of notification messages - */ - private function collectParticipantNotifications(BookingDto $bookingCreateDto): array - { - $notifications = []; - - foreach ($bookingCreateDto->participants as $participant) { - if ([] !== $participant->notifications) { - foreach ($participant->notifications as $notification) { - $notifications[] = $notification; - } - // Clear notifications after collection - $participant->notifications = []; - } - } - - return $notifications; - } -} diff --git a/src/Controller/Booking/DownloadController.php b/src/Controller/Booking/DownloadController.php index 3061fd4..fa72591 100644 --- a/src/Controller/Booking/DownloadController.php +++ b/src/Controller/Booking/DownloadController.php @@ -5,7 +5,7 @@ namespace App\Controller\Booking; use App\BusProNet\ApiClient; use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; -use App\Controller\Traits\BookingDataTrait; +use App\Controller\Booking\Traits\BookingDataTrait; use App\Entity\User; use App\Security\Crypt; use Psr\Log\LoggerInterface; diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php new file mode 100644 index 0000000..373e4c0 --- /dev/null +++ b/src/Controller/Booking/Edit/IndexController.php @@ -0,0 +1,523 @@ + '\d+'])] + #[IsGranted('ROLE_USER')] + public function index(int $id, Request $request): Response + { + /** @var User $user */ + $user = $this->getUser(); + $email = $user->getEmail(); + $password = $this->crypt->decrypt($user->getPassword()); + + // Load form data from session (or API on first load) + $bookingDto = $this->loadFormData($request, $id, $email, $password); + if (null === $bookingDto) { + return $this->redirectToRoute('app_bookings'); + } + + // Reset staleness timer when first loading the cards view (not HTMX requests) + // This prevents false staleness warnings from old edit sessions + if (false === $this->isHxRequest($request)) { + $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + } + + // Fetch booking data for display (surcharges, canceled status, etc.) + $bookingData = $this->fetchBookingData($email, $password, $id); + if (null === $bookingData || $bookingData instanceof Notification) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + + return $this->redirectToRoute('app_bookings'); + } + + // Fetch mutable data for form constraints + $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); + + // Create validation form (same pattern as CreateStep2Controller) + $form = $this->createForm(BookingEditType::class, $bookingDto, [ + 'validation_groups' => ['booking_edit'], + ]); + $form->handleRequest($request); + + // Handle form submission (clicking "Buchung aktualisieren") + if ($form->isSubmitted() && $form->isValid()) { + // All participants validated successfully, submit to API + $this->logger->info('Initiated booking update', [ + 'email' => $email, + 'booking_id' => $id, + ]); + + try { + $response = $this->apiClient->updateBooking($bookingDto, true); + if ($response instanceof Notification) { + if (true === $response->isError()) { + $this->addFlash('error', $response->message); + } else { + $this->addFlash('info', $response->message); + } + $this->logger->error('Booking update not successful', [ + 'email' => $email, + 'booking_id' => $id, + 'message' => $response->message, + ]); + } else { + try { + $cacheKey = sprintf('bpn_booking_%d', $id); + $this->cache->delete($cacheKey); + } catch (InvalidArgumentException $e) { + } + + // Clear session on successful save + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + + $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); + + $this->logger->info('Booking update successful', [ + 'email' => $email, + 'booking_id' => $id, + ]); + + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + } + } catch (ApiClientException $e) { + $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); + } + + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + } + + // Extract participant indices with validation errors + $participantErrors = []; + if ($form->isSubmitted() && false === $form->isValid()) { + $participantErrors = $this->extractParticipantErrorIndices($form); + } + + // Generate card data for all participants + $cardsData = $this->participantCardService->getAllCardsData($bookingDto); + + // Calculate summary data for sidebar + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); + $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingDto); + + // Group selected rooms for summary display + $availableRooms = $bookingDto->travel->getAvailableRooms(); + $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType( + $bookingDto->getSelectedRooms(), + $availableRooms + ); + + $templateData = [ + 'form' => $form->createView(), + 'bookingDto' => $bookingDto, + 'bookingData' => $bookingData, + 'mutableData' => $mutableData, + 'cardsData' => $cardsData, + 'participantsCount' => count($bookingDto->participants), + 'pricingData' => $summary['pricing'], + 'groupedSelectedRooms' => $groupedSelectedRooms, + 'assignmentCounts' => $roomAssignmentCounts, + 'isDirty' => $this->fingerprintService->isDirty($bookingDto), + 'hasValidationErrors' => count($participantErrors) > 0, + 'participantErrors' => $participantErrors, + ]; + + // If HTMX request, render only blocks to avoid layout duplication + if ($this->isHxRequest($request)) { + return $this->htmxOobResponse( + 'booking/edit/index.html.twig', + ['participant_cards', 'booking_summary'], + $templateData + ); + } + + // Regular request: render full template + return $this->render('booking/edit/index.html.twig', $templateData); + } + + /** + * Edit single participant form. + */ + #[Route( + path: '/bookings/{id}/edit/participants/{index}', + name: 'app_booking_edit_participant', + requirements: ['id' => '\d+', 'index' => '\d+'] + )] + #[IsGranted('ROLE_USER')] + public function editParticipant(int $id, int $index, Request $request): Response + { + /** @var User $user */ + $user = $this->getUser(); + $email = $user->getEmail(); + $password = $this->crypt->decrypt($user->getPassword()); + + // Load form data from session + $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + + if (null === $bookingDto) { + $this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.'); + + return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + } + + $participant = $bookingDto->participants[$index] ?? null; + + if (null === $participant) { + throw new \InvalidArgumentException('Invalid participant index'); + } + + // Fetch booking data to check for canceled status + $bookingData = $this->fetchBookingData($email, $password, $id); + if (null === $bookingData || $bookingData instanceof Notification) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + + return $this->redirectToRoute('app_bookings'); + } + + // Check if participant is canceled + $isCanceled = ($bookingData->participantsStatus[$index] ?? null) === 'S'; + + if ($isCanceled) { + // Redirect back to cards - canceled participants cannot be edited + $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); + + return $this->redirectToRoute('app_booking_edit', ['id' => $id]); + } + + // Create form for participant with booking context + $form = $this->createForm(BookingParticipantType::class, $participant, [ + 'booking_context' => $bookingDto, + 'edit_mode' => true, + 'validation_groups' => ['booking_edit'], + ]); + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // Save updated booking data to session + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + + // Redirect back to cards + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + } + + // Calculate summary data using trait helper + $summaryData = $this->calculateSummaryData($bookingDto); + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); + + // Fetch mutable data for form constraints + $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); + + // Render form and sidebar with OOB swap using htmxOobResponse + // This ensures both initial load and refresh use the same block-based rendering + return $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'bookingData' => $bookingData, + 'mutableData' => $mutableData, + 'summaryData' => $summaryData, + 'pricingData' => $summary['pricing'], + 'refreshRouteName' => 'app_booking_edit_participant_refresh', + 'refreshRouteParams' => ['id' => $id, 'index' => $index], + 'submitRouteName' => 'app_booking_edit_participant', + 'submitRouteParams' => ['id' => $id, 'index' => $index], + 'cancelRouteName' => 'app_booking_edit', + 'cancelRouteParams' => ['id' => $id], + ] + ); + } + + /** + * HTMX endpoint for refreshing participant form without validation. + */ + #[Route( + path: '/bookings/{id}/edit/participants/{index}/refresh', + name: 'app_booking_edit_participant_refresh', + requirements: ['id' => '\d+', 'index' => '\d+'], + methods: ['POST'] + )] + #[IsGranted('ROLE_USER')] + public function refreshParticipantForm(int $id, int $index, Request $request): Response + { + /** @var User $user */ + $user = $this->getUser(); + $email = $user->getEmail(); + $password = $this->crypt->decrypt($user->getPassword()); + + // Load form data from session + $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + + if (null === $bookingDto) { + return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); + } + + // Refresh availability data + $availabilities = $this->travelDataService->getAvailabilityDataCached($bookingDto->travel->id); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); + } + + // Fetch booking data and mutable data + $bookingData = $this->fetchBookingData($email, $password, $id); + $mutableData = null !== $bookingData && !($bookingData instanceof Notification) + ? $this->travelDataService->getMutabilityData($bookingData->dateId) + : null; + + // Create form with validation disabled + $form = $this->createForm(BookingParticipantType::class, $bookingDto->participants[$index], [ + 'booking_context' => $bookingDto, + 'edit_mode' => true, + 'validation_groups' => false, + ]); + + $form->handleRequest($request); + + // Save updated booking data to session + $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); + + // Collect notifications from participant DTO + $participant = $bookingDto->participants[$index] ?? null; + $notifications = $participant?->notifications ?? []; + + // Clear notifications after collecting + if (null !== $participant) { + $participant->notifications = []; + } + + // Calculate summary data using trait helper + $summaryData = $this->calculateSummaryData($bookingDto); + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); + + // Render form + sidebar using htmxOobResponse + $response = $this->htmxOobResponse( + 'booking/_participant_form.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form, + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'bookingData' => $bookingData, + 'mutableData' => $mutableData, + 'summaryData' => $summaryData, + 'pricingData' => $summary['pricing'], + 'refreshRouteName' => 'app_booking_edit_participant_refresh', + 'refreshRouteParams' => ['id' => $id, 'index' => $index], + 'submitRouteName' => 'app_booking_edit_participant', + 'submitRouteParams' => ['id' => $id, 'index' => $index], + 'cancelRouteName' => 'app_booking_edit', + 'cancelRouteParams' => ['id' => $id], + ] + ); + + // Add notifications to HX-Trigger header if present + if ([] !== $notifications) { + $response->headers->set('HX-Trigger', json_encode([ + 'showNotifications' => ['notifications' => $notifications], + ])); + } + + return $response; + } + + /** + * Reloads booking data from API, discarding all session changes. + */ + #[Route( + path: '/bookings/{id}/edit/reload', + name: 'app_booking_edit_reload', + requirements: ['id' => '\d+'], + methods: ['POST'] + )] + #[IsGranted('ROLE_USER')] + public function reloadFromApi(int $id, Request $request): Response + { + // Clear session to discard all changes + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + + $this->addFlash('success', 'Änderungen verworfen, Daten neu geladen'); + + return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); + } + + /** + * Handles "Zurück" button - clears session and returns to bookings list. + */ + #[Route( + path: '/bookings/{id}/edit/cancel', + name: 'app_booking_edit_cancel', + requirements: ['id' => '\d+'], + methods: ['POST'] + )] + #[IsGranted('ROLE_USER')] + public function cancelEdit(Request $request): Response + { + // Clear session to discard dirty state + $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); + + return $this->hxRedirect($request, $this->generateUrl('app_bookings')); + } + + /** + * Loads form data from session or initializes from API on first load. + * + * @return BookingDto|null The form data, or null on error + */ + private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto + { + // Try to load from session first + $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); + + if (null === $formData) { + // First load: initialize from API + return $this->initializeFromApi($request, $bookingId, $email, $password); + } + + // Subsequent load: refresh from session with staleness check + return $this->refreshFromSession($formData); + } + + /** + * Initializes form data from API on first load and stores in session. + * + * @return BookingDto|null The form data, or null on error + */ + private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto + { + $bookingData = $this->fetchBookingData($email, $password, $bookingId); + + if (null === $bookingData || $bookingData instanceof Notification) { + $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); + + return null; + } + + $this->denyAccessUnlessGranted('EDIT', $bookingData); + + $travelData = $this->travelDataService->getTravelData($bookingData->dateId); + if (null === $travelData) { + $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); + + return null; + } + + $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); + $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); + + if (null === $mutableData || null === $availabilities) { + $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); + + return null; + } + + $this->travelDataService->patchAvailabilities($travelData, $availabilities); + $this->travelDataService->patchMutability($travelData, $mutableData); + + $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); + + // Set original fingerprint for dirty state detection + error_log('[Fingerprint] === GENERATING ORIGINAL FINGERPRINT ==='); + $formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true); + + $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); + + return $formData; + } + + /** + * Refreshes form data loaded from session with latest availability. + * + * @return BookingDto The refreshed form data + */ + private function refreshFromSession(BookingDto $formData): BookingDto + { + // Refresh availability data + $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); + if (null !== $availabilities) { + $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); + } + + // Show staleness warning if session is older than 5 minutes + if (null !== $formData->lastSessionUpdate) { + $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); + if ($ageInSeconds > 300) { + $minutes = (int) ceil($ageInSeconds / 60); + $this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes)); + } + } + + return $formData; + } +} diff --git a/src/Controller/Booking/EditController.php b/src/Controller/Booking/EditController.php deleted file mode 100644 index 80e25e0..0000000 --- a/src/Controller/Booking/EditController.php +++ /dev/null @@ -1,363 +0,0 @@ - '\d+'])] - #[IsGranted('ROLE_USER')] - public function edit(int $id, Request $request): Response - { - /** @var User $user */ - $user = $this->getUser(); - $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); - - // Load form data from session (or API on first load) - $formData = $this->loadFormData($request, $id, $email, $password); - if (null === $formData) { - return $this->redirectToRoute('app_bookings'); - } - - // Fetch booking data for display (surcharges, canceled status, etc.) - $bookingData = $this->fetchBookingData($email, $password, $id); - if (null === $bookingData || $bookingData instanceof Notification) { - $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); - - return $this->redirectToRoute('app_bookings'); - } - - // Fetch mutable data for form constraints - $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - - // Calculate pricing data for template - $summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData); - $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($formData); - $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($formData); - - // Group selected rooms for summary display - $availableRooms = $formData->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType( - $formData->getSelectedRooms(), - $availableRooms - ); - - $form = $this->createForm(BookingEditType::class, $formData, [ - 'attr' => [ - 'novalidate' => 'novalidate', - 'hx-post' => $this->generateUrl('app_booking_edit', ['id' => $id]), - 'hx-target' => '#form-wrapper', - 'hx-select' => '#form-wrapper', - 'hx-swap' => 'outerHTML', - ], - 'validation_groups' => ['booking_edit'], - ]); - - $form->handleRequest($request); - - if ($form->isSubmitted() && $form->isValid()) { - $this->logger->info('Initiated booking update', [ - 'email' => $email, - 'booking_id' => $id, - ]); - - try { - $response = $this->apiClient->updateBooking($formData, true); - if ($response instanceof Notification) { - if (true === $response->isError()) { - $this->addFlash('error', $response->message); - } else { - $this->addFlash('info', $response->message); - } - $this->logger->error('Booking update not successful', [ - 'email' => $email, - 'booking_id' => $id, - 'message' => $response->message, - ]); - } else { - try { - $cacheKey = sprintf('bpn_booking_%d', $id); - $this->cache->delete($cacheKey); - } catch (InvalidArgumentException $e) { - } - - // Clear session on successful save - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); - - $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); - - $this->logger->info('Booking update successful', [ - 'email' => $email, - 'booking_id' => $id, - ]); - - return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); - } - } catch (ApiClientException $e) { - $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); - } - - return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); - } - - return $this->render('booking/edit.html.twig', [ - 'bookingData' => $bookingData, - 'bookingEditDto' => $formData, - 'mutableData' => $mutableData, - 'form' => $form->createView(), - 'pricingData' => $summary['pricing'], - 'participantCount' => $summary['participantCount'], - 'assignmentCounts' => $roomAssignmentCounts, - 'participantPrices' => $participantPrices, - 'groupedSelectedRooms' => $groupedSelectedRooms, - ]); - } - - /** - * Reloads booking data from API, discarding all session changes. - */ - #[Route('/bookings/{id}/edit/reload', name: 'app_booking_edit_reload', requirements: ['id' => '\d+'], methods: ['POST'])] - #[IsGranted('ROLE_USER')] - public function reloadFromApi(int $id, Request $request): Response - { - // Clear session to discard all changes - $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); - - $this->addFlash('success', 'Änderungen verworfen, Daten neu geladen'); - - return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); - } - - /** - * HTMX endpoint for refreshing the participant form without validation. - */ - #[Route('/bookings/{id}/edit/refresh', name: 'app_booking_edit_refresh', requirements: ['id' => '\d+'], methods: ['POST'])] - #[IsGranted('ROLE_USER')] - public function refreshParticipantForm(int $id, Request $request): Response - { - // Load form data from session - $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); - - if (null === $formData) { - return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); - } - - // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); - } - - // Process form without validation to capture current state - $form = $this->createForm(BookingEditType::class, $formData, [ - 'attr' => ['novalidate' => 'novalidate'], - 'validation_groups' => false, - ]); - - $form->handleRequest($request); - - // Save updated DTO back to session - $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); - - // Collect notifications from all participants - $notifications = $this->collectParticipantNotifications($formData); - - // Fetch booking data and mutable data for display - /** @var User $user */ - $user = $this->getUser(); - $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); - - $bookingData = $this->fetchBookingData($email, $password, $id); - $mutableData = null !== $bookingData && !($bookingData instanceof Notification) - ? $this->travelDataService->getMutabilityData($bookingData->dateId) - : null; - - // Calculate pricing and summary data - $summary = $this->bookingService->getRoomSummaryAndParticipantCount($formData); - $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($formData); - $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($formData); - - // Group selected rooms for summary display - $availableRooms = $formData->travel->getAvailableRooms(); - $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType( - $formData->getSelectedRooms(), - $availableRooms - ); - - // Render updated blocks with fresh data - $response = $this->htmxOobResponse( - 'booking/edit.html.twig', - ['participants_form', 'booking_summary'], - [ - 'form' => $form->createView(), - 'bookingEditDto' => $formData, - 'bookingData' => $bookingData, - 'pricingData' => $summary['pricing'], - 'participantCount' => $summary['participantCount'], - 'assignmentCounts' => $roomAssignmentCounts, - 'participantPrices' => $participantPrices, - 'groupedSelectedRooms' => $groupedSelectedRooms, - 'mutableData' => $mutableData, - ] - ); - - // Add notifications to HTMX trigger header if any exist - if ([] !== $notifications) { - $response->headers->set('HX-Trigger', json_encode([ - 'showNotifications' => ['notifications' => $notifications], - ])); - } - - return $response; - } - - /** - * Collects all notifications from participants and clears them. - * - * @return array Array of notification messages - */ - private function collectParticipantNotifications(BookingDto $bookingDto): array - { - $notifications = []; - - foreach ($bookingDto->participants as $participant) { - if ([] !== $participant->notifications) { - foreach ($participant->notifications as $notification) { - $notifications[] = $notification; - } - // Clear notifications after collecting - $participant->notifications = []; - } - } - - return $notifications; - } - - /** - * Loads form data from session or initializes from API on first load. - * - * @return BookingDto|null The form data, or null on error - */ - private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto - { - // Try to load from session first - $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); - - if (null === $formData) { - // First load: initialize from API - return $this->initializeFromApi($request, $bookingId, $email, $password); - } - - // Subsequent load: refresh from session with staleness check - return $this->refreshFromSession($formData); - } - - /** - * Initializes form data from API on first load and stores in session. - * - * @return BookingDto|null The form data, or null on error - */ - private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto - { - $bookingData = $this->fetchBookingData($email, $password, $bookingId); - - if (null === $bookingData || $bookingData instanceof Notification) { - $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); - - return null; - } - - $this->denyAccessUnlessGranted('EDIT', $bookingData); - - $travelData = $this->travelDataService->getTravelData($bookingData->dateId); - if (null === $travelData) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return null; - } - - $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); - $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); - - if (null === $mutableData || null === $availabilities) { - $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); - - return null; - } - - $this->travelDataService->patchAvailabilities($travelData, $availabilities); - $this->travelDataService->patchMutability($travelData, $mutableData); - - $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); - $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); - - return $formData; - } - - /** - * Refreshes form data loaded from session with latest availability. - * - * @return BookingDto The refreshed form data - */ - private function refreshFromSession(BookingDto $formData): BookingDto - { - // Refresh availability data - $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); - if (null !== $availabilities) { - $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); - } - - // Show staleness warning if session is older than 5 minutes - if (null !== $formData->lastSessionUpdate) { - $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); - if ($ageInSeconds > 300) { - $minutes = (int) ceil($ageInSeconds / 60); - $this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes)); - } - } - - return $formData; - } -} diff --git a/src/Controller/Booking/BookingCreateTrait.php b/src/Controller/Booking/Traits/BookingCreateTrait.php similarity index 98% rename from src/Controller/Booking/BookingCreateTrait.php rename to src/Controller/Booking/Traits/BookingCreateTrait.php index ab42604..3c46169 100644 --- a/src/Controller/Booking/BookingCreateTrait.php +++ b/src/Controller/Booking/Traits/BookingCreateTrait.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Controller\Booking; +namespace App\Controller\Booking\Traits; use App\Form\Model\BookingDto; use Symfony\Component\HttpFoundation\RedirectResponse; diff --git a/src/Controller/Traits/BookingDataTrait.php b/src/Controller/Booking/Traits/BookingDataTrait.php similarity index 94% rename from src/Controller/Traits/BookingDataTrait.php rename to src/Controller/Booking/Traits/BookingDataTrait.php index 7ea8614..2ef45e0 100644 --- a/src/Controller/Traits/BookingDataTrait.php +++ b/src/Controller/Booking/Traits/BookingDataTrait.php @@ -1,6 +1,6 @@ bookingService->getBookingDto($request, $mode); + + if (null === $bookingDto) { + throw new \RuntimeException(sprintf('Booking data not found in session for mode: %s', $mode)); + } + + return $bookingDto; + } + + /** + * Generate card data for all participants. + * + * @return array + */ + private function generateAllCardsData(BookingDto $bookingDto): array + { + return $this->participantCardService->getAllCardsData($bookingDto); + } + + /** + * Create form for single participant. + * + * This creates an autonomous participant form with booking_context option + * so it can process field handlers independently. + */ + private function createParticipantForm( + BookingDto $bookingDto, + int $index, + array $options = [], + ): FormInterface { + $participant = $bookingDto->participants[$index] ?? null; + + if (null === $participant) { + throw new \InvalidArgumentException(sprintf('Participant at index %d does not exist', $index)); + } + + // Merge default options with provided options + $formOptions = array_merge([ + 'booking_context' => $bookingDto, + 'edit_mode' => BookingDto::MODE_EDIT === $bookingDto->getMode(), + ], $options); + + return $this->createForm(BookingParticipantType::class, $participant, $formOptions); + } + + /** + * Calculate summary data (pricing, room counts, etc.). + * + * @return array{ + * participantsCount: int, + * totalPrice: string, + * groupedSelectedRooms: array, + * assignmentCounts: array + * } + */ + private function calculateSummaryData(BookingDto $bookingDto): array + { + // Calculate individual prices for all participants + $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto); + + // Calculate total price + $totalPrice = array_sum($participantPrices); + + // Get room assignment counts + $roomCounts = []; + foreach ($bookingDto->participants as $participant) { + if (null !== $participant->assignedRoomId) { + $roomCounts[$participant->assignedRoomId] = ($roomCounts[$participant->assignedRoomId] ?? 0) + 1; + } + } + + // Group selected rooms with counts + $groupedSelectedRooms = []; + foreach ($roomCounts as $roomId => $count) { + $room = $bookingDto->travel->getRoomById($roomId); + if (null !== $room) { + $groupedSelectedRooms[] = [ + 'room' => $room, + 'count' => $count, + ]; + } + } + + return [ + 'participantsCount' => count($bookingDto->participants), + 'totalPrice' => number_format($totalPrice, 2, ',', '.').' €', + 'groupedSelectedRooms' => $groupedSelectedRooms, + 'assignmentCounts' => $roomCounts, + ]; + } + + /** + * Process single participant form refresh. + * + * Handles HTMX form refresh without validation, updates sidebar via OOB swap. + */ + private function handleParticipantRefresh( + Request $request, + BookingDto $bookingDto, + int $index, + string $refreshRouteName, + string $submitRouteName, + ): Response { + // Create form with validation disabled + $form = $this->createParticipantForm($bookingDto, $index, [ + 'validation_groups' => false, + ]); + + $form->handleRequest($request); + + // Save updated booking data to session + $this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode()); + + // Collect notifications from participant DTO + $participant = $bookingDto->participants[$index] ?? null; + $notifications = $participant?->notifications ?? []; + + // Clear notifications after collecting + if (null !== $participant) { + $participant->notifications = []; + } + + // Calculate summary data for sidebar + $summaryData = $this->calculateSummaryData($bookingDto); + + // Get detailed pricing data for summary sidebar + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); + + // Render form and sidebar with OOB swap using htmxOobResponse + // This renders ONLY the specific blocks, not the entire template + $response = $this->htmxOobResponse( + 'booking/_participant_form_standalone.html.twig', + ['participant_form', 'booking_summary'], + [ + 'form' => $form->createView(), + 'participantIndex' => $index, + 'bookingDto' => $bookingDto, + 'summaryData' => $summaryData, + 'pricingData' => $summary['pricing'], + 'refreshRouteName' => $refreshRouteName, + 'submitRouteName' => $submitRouteName, + ] + ); + + // Add notifications to HX-Trigger header if present + if (false === empty($notifications)) { + $response->headers->set('HX-Trigger', json_encode([ + 'showNotifications' => $notifications, + ])); + } + + return $response; + } + + /** + * Required services - implementing controllers must inject these. + * + * Controllers using this trait must have the following properties: + * - BookingService $bookingService + * - ParticipantCardDataService $participantCardService + * - BookingPriceCalculatorService $priceCalculator + */ + abstract private function createForm(string $type, $data = null, array $options = []): FormInterface; + + abstract private function render(string $view, array $parameters = [], ?Response $response = null): Response; +} diff --git a/src/Controller/Booking/Traits/ParticipantValidationTrait.php b/src/Controller/Booking/Traits/ParticipantValidationTrait.php new file mode 100644 index 0000000..d3811a0 --- /dev/null +++ b/src/Controller/Booking/Traits/ParticipantValidationTrait.php @@ -0,0 +1,43 @@ + Array of participant indices with errors + */ + private function extractParticipantErrorIndices($form): array + { + $errorIndices = []; + $errors = $form->getErrors(true); // Get all errors recursively + + foreach ($errors as $error) { + $propertyPath = $error->getCause()?->getPropertyPath(); + if (null === $propertyPath) { + continue; + } + + // Property paths look like "participants[0].firstName" or "participants[1].email" + if (preg_match('/participants\[(\d+)]/', $propertyPath, $matches)) { + $index = (int) $matches[1]; + $errorIndices[$index] = true; // Use array key to avoid duplicates + } + } + + return array_keys($errorIndices); + } +} diff --git a/src/Form/BookingCreateStep2Type.php b/src/Form/BookingCreateStep2Type.php index 1b3eadf..8d43048 100644 --- a/src/Form/BookingCreateStep2Type.php +++ b/src/Form/BookingCreateStep2Type.php @@ -1,81 +1,27 @@ 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 diff --git a/src/Form/BookingEditType.php b/src/Form/BookingEditType.php index 0afdc86..4ed32ce 100644 --- a/src/Form/BookingEditType.php +++ b/src/Form/BookingEditType.php @@ -1,71 +1,27 @@ 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 diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 94bdb51..e49fa9d 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -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 $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]); } } diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 54d2f97..6c32365 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -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) { } diff --git a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php index 93f709a..c400430 100644 --- a/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php +++ b/src/Form/Service/ParticipantAdditionalServicesFieldHandler.php @@ -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; } /** diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index 2821cfe..e7bd203 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -76,7 +76,7 @@ class ParticipantFieldHandlerRegistry * automatically cleared. * * @param array $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 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 $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 $participantData Submitted data for one participant - * @param BookingDto $bookingDto The booking DTO to update - * @param int $participantIndex Index of participant to process + * @param array $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 $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 Updated submitted data reflecting DTO state */ @@ -326,7 +326,7 @@ class ParticipantFieldHandlerRegistry * @param array $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 Updated participant data with synchronized field values */ diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 8b6de7b..be225ca 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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. * diff --git a/src/Service/BookingFingerprintService.php b/src/Service/BookingFingerprintService.php new file mode 100644 index 0000000..1102201 --- /dev/null +++ b/src/Service/BookingFingerprintService.php @@ -0,0 +1,172 @@ + $bookingDto->paymentMethod, + 'bankAccount' => [ + 'iban' => $bookingDto->bankAccount?->iban, + 'accountHolder' => $bookingDto->bankAccount?->accountHolder, + ], + 'participants' => [], + ]; + + foreach ($bookingDto->participants as $index => $participant) { + $data['participants'][$index] = [ + 'personalData' => [ + 'firstName' => $participant->firstName, + 'lastName' => $participant->lastName, + 'dateOfBirth' => $participant->dateOfBirth?->format('Y-m-d'), + 'email' => $participant->email, + 'mobile' => $participant->mobile, + 'gender' => $participant->gender, + 'nationality' => $participant->nationality, + ], + 'address' => [ + 'street' => $participant->address?->street, + 'postCode' => $participant->address?->postCode, + 'city' => $participant->address?->city, + 'country' => $participant->address?->country, + ], + 'bodyDimensions' => [ + 'height' => $participant->height, + 'weight' => $participant->weight, + 'shoeSize' => $participant->shoeSize, + ], + 'roomAssignment' => [ + 'assignedRoomId' => $participant->assignedRoomId, + 'remarksRoom' => $participant->remarksRoom, + ], + 'licensePlate' => $participant->licensePlate, + 'services' => [ + 'skiPass' => $participant->skiPass?->id, + 'courses' => $this->normalizeServiceArray($participant->courses), + 'board' => $this->normalizeServiceArray($participant->board), + 'rentals' => $this->normalizeServiceArray($participant->rentals), + 'rentalInsurance' => $participant->rentalInsurance?->id, + 'additionalServices' => $this->normalizeServiceArray($participant->additionalServices), + 'transportationOutbound' => $participant->transportationOutbound?->id, + 'transportationInbound' => $participant->transportationInbound?->id, + 'pickup' => $participant->pickup?->id, + 'parking' => $participant->parking, + 'insurance' => $participant->insurance?->id, + 'bulkInsuranceBooking' => $participant->bulkInsuranceBooking, + ], + ]; + } + + $fingerprint = hash('sha256', serialize($data)); + + if ($logData) { + error_log(sprintf('[Fingerprint] Generated fingerprint: %s', $fingerprint)); + error_log(sprintf('[Fingerprint] Serialized data: %s', serialize($data))); + } + + return $fingerprint; + } + + /** + * Normalizes a service array to ensure consistent fingerprinting. + * + * Extracts service IDs, sorts them, and returns a simple indexed array. + * This ensures that associative arrays, indexed arrays, and different orders + * all produce the same fingerprint as long as the same services are present. + * + * @param array $services Array of Service objects + * + * @return array Sorted array of service IDs + */ + private function normalizeServiceArray(array $services): array + { + $ids = array_map(fn ($s) => $s->id, $services); + sort($ids); + + return array_values($ids); + } + + /** + * Checks if the booking has unsaved changes in edit mode. + * + * Compares the current state fingerprint with the original fingerprint + * that was set when the booking was loaded from the API. + */ + public function isDirty(BookingDto $bookingDto): bool + { + if (BookingDto::MODE_EDIT !== $bookingDto->getMode()) { + return false; + } + + if (null === $bookingDto->originalFingerprint) { + return false; + } + + $currentFingerprint = $this->generateFingerprint($bookingDto); + $isDirty = $bookingDto->originalFingerprint !== $currentFingerprint; + + // Debug logging to identify what changed + if ($isDirty) { + error_log(sprintf('[Fingerprint] DIRTY DETECTED! Original: %s, Current: %s', $bookingDto->originalFingerprint, $currentFingerprint)); + $this->logFingerprintDiff($bookingDto); + } + + return $isDirty; + } + + /** + * Logs detailed fingerprint data for debugging dirty state issues. + */ + private function logFingerprintDiff(BookingDto $bookingDto): void + { + foreach ($bookingDto->participants as $index => $participant) { + $participantData = [ + 'firstName' => $participant->firstName, + 'lastName' => $participant->lastName, + 'dateOfBirth' => $participant->dateOfBirth?->format('Y-m-d'), + 'email' => $participant->email, + 'mobile' => $participant->mobile, + 'gender' => $participant->gender, + 'nationality' => $participant->nationality, + 'address' => [ + 'street' => $participant->address?->street, + 'postCode' => $participant->address?->postCode, + 'city' => $participant->address?->city, + 'country' => $participant->address?->country, + ], + 'services' => [ + 'skiPass' => $participant->skiPass?->id, + 'courses' => $this->normalizeServiceArray($participant->courses), + 'board' => $this->normalizeServiceArray($participant->board), + 'rentals' => $this->normalizeServiceArray($participant->rentals), + 'rentalInsurance' => $participant->rentalInsurance?->id, + 'additionalServices' => $this->normalizeServiceArray($participant->additionalServices), + 'transportationOutbound' => $participant->transportationOutbound?->id, + 'transportationInbound' => $participant->transportationInbound?->id, + 'pickup' => $participant->pickup?->id, + 'parking' => $participant->parking, + ], + ]; + + error_log(sprintf('[Fingerprint] Participant %d data: %s', $index, json_encode($participantData))); + } + } +} diff --git a/src/Service/BookingPriceCalculatorService.php b/src/Service/BookingPriceCalculatorService.php index 6015fa2..236d850 100644 --- a/src/Service/BookingPriceCalculatorService.php +++ b/src/Service/BookingPriceCalculatorService.php @@ -56,6 +56,12 @@ class BookingPriceCalculatorService { $roomPricing = []; + // In edit mode, use room data from the booking entity + if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $bookingDto->booking) { + return $this->calculateRoomPricingFromBooking($bookingDto); + } + + // In create mode, use room selections from the form $selectedRooms = $bookingDto->getSelectedRooms(); if (true === empty($selectedRooms)) { return $roomPricing; @@ -86,6 +92,61 @@ class BookingPriceCalculatorService return $roomPricing; } + /** + * Calculates room pricing from booking entity data (edit mode). + * + * In edit mode, room prices come from the booking entity's individualPrice arrays. + * Each participant has their room price stored in the room's individualPrice array. + * + * @param BookingDto $bookingDto The booking data with booking entity + * + * @return array Array of room pricing data with labels, quantities, and totals + */ + private function calculateRoomPricingFromBooking(BookingDto $bookingDto): array + { + $roomPricing = []; + $roomGroups = []; + + // Group participants by room and sum their individual prices + foreach ($bookingDto->booking->rooms as $room) { + if (false === isset($roomGroups[$room->id])) { + $roomGroups[$room->id] = [ + 'room' => $room, + 'participantCount' => 0, + 'totalPrice' => 0.0, + ]; + } + + // Sum individual prices for all participants in this room + foreach ($room->mapping as $participantIndex) { + $individualPrice = $room->individualPrice[$participantIndex] ?? 0.0; + $roomGroups[$room->id]['totalPrice'] += $individualPrice; + ++$roomGroups[$room->id]['participantCount']; + } + } + + // Build pricing array + foreach ($roomGroups as $roomId => $data) { + $room = $data['room']; + $participantCount = $data['participantCount']; + $totalPrice = $data['totalPrice']; + + // Calculate average unit price (price per person) + $unitPrice = $participantCount > 0 ? $totalPrice / $participantCount : 0.0; + + $roomPricing[] = [ + 'roomId' => $room->id, + 'label' => $room->label, + 'quantity' => $room->totalCount, + 'participantCount' => $participantCount, + 'unitPrice' => $unitPrice, + 'totalPrice' => $totalPrice, + ]; + } + + return $roomPricing; + } + /** * Calculates pricing for all selected services across all participants, grouped by subtype. * @@ -516,9 +577,9 @@ class BookingPriceCalculatorService /** * Calculates the total service cost for a single participant. * - * @param ParticipantDto $participant The participant to calculate services for - * @param bool $includeInsurance Whether to include insurance pricing (default: true) - * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution + * @param ParticipantDto $participant The participant to calculate services for + * @param bool $includeInsurance Whether to include insurance pricing (default: true) + * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) * * @return float The total service cost for this participant diff --git a/src/Service/ParticipantCardDataService.php b/src/Service/ParticipantCardDataService.php index dd02413..f18012d 100644 --- a/src/Service/ParticipantCardDataService.php +++ b/src/Service/ParticipantCardDataService.php @@ -72,7 +72,7 @@ class ParticipantCardDataService $firstName = $participant->firstName ?? ''; $lastName = $participant->lastName ?? ''; - $name = trim($firstName . ' ' . $lastName); + $name = trim($firstName.' '.$lastName); if ('' === $name) { return sprintf('Teilnehmer %d', $index + 1); @@ -98,7 +98,7 @@ class ParticipantCardDataService return 'Unbekanntes Zimmer'; } - return $room->name; + return $room->label; } /** @@ -110,6 +110,6 @@ class ParticipantCardDataService $price = $prices[$index] ?? 0.0; - return number_format($price, 2, ',', '.') . ' €'; + return number_format($price, 2, ',', '.').' €'; } -} \ No newline at end of file +} diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig new file mode 100644 index 0000000..cc55a4b --- /dev/null +++ b/templates/booking/_participant_card.html.twig @@ -0,0 +1,58 @@ +{# Compact participant card with name, room, price, and edit button #} +{% set isCanceled = isCanceled|default(false) %} +{% set hasErrors = hasErrors|default(false) %} +{% set mode = mode|default('create') %} + +
+
+
+

+ {{ cardData.name }} +

+ {% if isCanceled %} + + storniert + + {% elseif hasErrors %} + + + + + Unvollständig + + {% endif %} +
+

{{ cardData.roomName }}

+
+
+ {{ cardData.price }} + {% if isCanceled %} + + {% else %} + {% if mode == 'edit' %} + + {% else %} + + {% endif %} + {% endif %} +
+
diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig new file mode 100644 index 0000000..73132fc --- /dev/null +++ b/templates/booking/_participant_form.html.twig @@ -0,0 +1,327 @@ +{% import _self as macros %} + +{# Macro to render a field or placeholder with consistent fieldset structure #} +{% macro service_field(form, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} + {% if form[fieldName] is defined %} + {{ form_row(form[fieldName], options) }} + {% else %} +
+ {{ label }} +
{{ undefinedLabel }}
+
+ {% endif %} +{% endmacro %} + +{# Specialized macro for checkbox fields (like rental insurance) that need manual fieldset wrapping #} +{% macro checkbox_field(form, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} + {% if form[fieldName] is defined %} +
+ {{ label }} + {{ form_row(form[fieldName], options) }} +
+ {% else %} +
+ {{ label }} +
{{ undefinedLabel }}
+
+ {% endif %} +{% endmacro %} + +{# Standalone participant form view (replaces main content area) #} +{% block participant_form %} +{% import _self as macros %} +
+

{{ participantIndex == 0 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ (participantIndex + 1) }}

+ + {{ form_start(form, { + 'attr': { + 'novalidate': 'novalidate', + 'hx-post': path(submitRouteName, submitRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} +
+ {# Personal data section #} +
+ {{ form_row(form.firstName) }} + {{ form_row(form.lastName) }} + {{ form_row(form.dateOfBirth, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} + {{ form_row(form.gender) }} + {{ form_row(form.nationality) }} +
+ + {# Contact information #} +
+ {{ form_row(form.email) }} + {{ form_row(form.mobile) }} +
+ + {# Address #} + {% if form.address is defined %} +
+ {{ form_row(form.address.street) }} + {{ form_row(form.address.postCode) }} + {{ form_row(form.address.city) }} + {{ form_row(form.address.country) }} +
+ {% endif %} + + {# Body dimensions #} + {% if form.bodyDimensions is defined %} +
+ {{ form_row(form.bodyDimensions.height) }} + {{ form_row(form.bodyDimensions.shoeSize) }} + {{ form_row(form.bodyDimensions.weight) }} +
+ {% endif %} + + {# Room assignment #} +
+ {{ form_row(form.assignedRoomId, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} + {% if form.remarksRoom is defined %} + {{ form_row(form.remarksRoom) }} + {% endif %} +
+ + {# Eligibility checks #} + {% set participantData = form.vars.data %} + {% set hasDateOfBirth = participantData and participantData.dateOfBirth %} + {% set isEligible = hasDateOfBirth and is_participant_eligible(bookingDto, participantIndex) %} + + {% if not hasDateOfBirth %} +
+
+ + + +

+ Leistungen sind erst nach Angabe des Geburtsdatums buchbar +

+
+
+ {% elseif not isEligible %} +
+
+ + + +

+ Buchung wegen des Alters von Teilnehmer:in {{ participantIndex + 1 }} nicht möglich +

+
+
+ {% endif %} + + {# Service selection #} + {% if isEligible %} +
+ {{ macros.service_field(form, 'skiPass', 'Skipass', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + + {{ macros.service_field(form, 'courses', 'Kurse', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + + {{ macros.service_field(form, 'additionalServices', 'Zusatzleistungen', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + + {{ macros.service_field(form, 'rentals', 'Leihmaterial', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }, 'Bitte zuerst den Skipass auswählen') }} + + {{ macros.checkbox_field(form, 'rentalInsurance', 'Leihmaterial-Versicherung', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }, 'Nur bei Buchung von Leihmaterial') }} + + {{ macros.service_field(form, 'board', 'Verpflegung', { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + +
+ {# Insurance field OR assigned insurance display for dependent participants #} + {% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %} + + {% if showBulkInsurance %} +
+ Reiseversicherung +
+ {% set applicantInsurance = bookingDto.participants[0].insurance %} + {% if applicantInsurance %} + {{ applicantInsurance.label }} + {% if applicantInsurance.price and applicantInsurance.price > 0 %} + (€{{ applicantInsurance.price|number_format(2, ',', '.') }}) + {% endif %} + – wie Anmelder + {% else %} + wie Anmelder + {% endif %} +
+
+ {% else %} +
+ Reiseversicherung + + {# Bulk insurance booking checkbox (applicant only) #} + {% if form.bulkInsuranceBooking is defined %} + {{ form_row(form.bulkInsuranceBooking) }} + {% endif %} + + {% if form.insurance is defined %} + {{ form_row(form.insurance, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + }, + 'label': false + }) }} + {% else %} +
Nicht wählbar
+ {% endif %} +
+ {% endif %} +
+ +
+ + {# Transportation Services Section #} +
+

Anreise

+
+
+ {% if form.transportationOutbound is defined %} + {{ form_row(form.transportationOutbound, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + {% endif %} + {% if form.pickup is defined or form.parking is defined or form.licensePlate is defined %} + {% if form.pickup is defined %} +
+ {{ form_row(form.pickup, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} +
+ {% endif %} + {% if form.parking is defined %} +
+ {{ form_row(form.parking, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} +
+ {% endif %} + {% if form.licensePlate is defined %} +
+ {{ form_row(form.licensePlate) }} +
+ {% endif %} + {% endif %} +
+
+ {% if form.transportationInbound is defined %} + {{ form_row(form.transportationInbound, { + 'attr': { + 'hx-trigger': 'change', + 'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})), + 'hx-target': '#main-content', 'hx-swap': 'innerHTML' + } + }) }} + {% endif %} +
+
+
+ {% endif %} +
+ +
+ {% if cancelRouteName is defined %} + + {% else %} + + {% endif %} + +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +
+{% endblock %} + +{# Sidebar summary with conditional OOB swap #} +{% block booking_summary %} +
+ {% include 'booking/_summary.html.twig' with { + 'bookingCreateDto': bookingDto, + 'participantCount': summaryData.participantsCount, + 'groupedSelectedRooms': summaryData.groupedSelectedRooms, + 'assignmentCounts': summaryData.assignmentCounts, + 'pricingData': pricingData + } %} +
+{% endblock %} diff --git a/templates/booking/create_error.html.twig b/templates/booking/create/error.html.twig similarity index 79% rename from templates/booking/create_error.html.twig rename to templates/booking/create/error.html.twig index 8c073e2..b61085a 100644 --- a/templates/booking/create_error.html.twig +++ b/templates/booking/create/error.html.twig @@ -19,20 +19,15 @@
{% for flash_message in app.flashes('error') %}

{{ flash_message }}

+ {% else %} +

Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.

{% endfor %} - - {% if app.flashes('error') is empty %} -

Es ist ein Fehler beim Starten der Buchung aufgetreten. Bitte versuchen Sie es erneut.

- {% endif %}
- + Zur Startseite -
diff --git a/templates/booking/create_step_1.html.twig b/templates/booking/create/step_1.html.twig similarity index 100% rename from templates/booking/create_step_1.html.twig rename to templates/booking/create/step_1.html.twig diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig new file mode 100644 index 0000000..ebd6478 --- /dev/null +++ b/templates/booking/create/step_2.html.twig @@ -0,0 +1,76 @@ +{% extends 'layout.html.twig' %} + +{% block content %} + {% include '_partials/_flashes.html.twig' %} +
+

Neue Buchung

+ +
+ {# Main content area - cards grid #} + {% block participant_cards %} +
+ {{ form_start(form, { + 'attr': { + 'hx-post': path('app_booking_create_step_2'), + 'hx-target': '#main-content', + 'hx-swap': 'innerHTML' + } + }) }} + +

Teilnehmer

+ + {# Display form-level validation errors #} + {% if form.vars.submitted and not form.vars.valid %} +
+
+ + + +
+

Bitte überprüfe die Teilnehmerdaten

+

Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.

+
+
+
+ {% endif %} + +
+ {% for cardData in cardsData %} + {% set hasErrors = loop.index0 in participantErrors|default([]) %} + {% include 'booking/_participant_card.html.twig' with { + 'cardData': cardData, + 'index': loop.index0, + 'mode': 'create', + 'hasErrors': hasErrors + } %} + {% endfor %} +
+ +
+ + Zurück + + +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +
+ {% endblock %} + + {# Sidebar summary #} + {% block booking_summary %} +
+ {% include 'booking/_summary.html.twig' with { + 'bookingCreateDto': bookingDto, + 'participantCount': summaryData.participantsCount, + 'groupedSelectedRooms': summaryData.groupedSelectedRooms, + 'assignmentCounts': summaryData.assignmentCounts, + 'pricingData': pricingData + } %} +
+ {% endblock %} +
+{% endblock %} diff --git a/templates/booking/create_step_3.html.twig b/templates/booking/create/step_3.html.twig similarity index 100% rename from templates/booking/create_step_3.html.twig rename to templates/booking/create/step_3.html.twig diff --git a/templates/booking/create_step_4.html.twig b/templates/booking/create/step_4.html.twig similarity index 100% rename from templates/booking/create_step_4.html.twig rename to templates/booking/create/step_4.html.twig diff --git a/templates/booking/success.html.twig b/templates/booking/create/success.html.twig similarity index 53% rename from templates/booking/success.html.twig rename to templates/booking/create/success.html.twig index 8986838..8721bfe 100644 --- a/templates/booking/success.html.twig +++ b/templates/booking/create/success.html.twig @@ -4,22 +4,15 @@ {% block content %}
-
- - - -
-

Buchung erfolgreich abgeschlossen

- Ihre Buchungsnummer: {{ bookingNumber }} + Deine Buchungsnummer lautet {{ bookingNumber }}

- Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details zu Ihrer Buchung. + Du erhältst in Kürze eine Bestätigungs-E-Mail mit allen Details zu Deiner Buchung.

diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig deleted file mode 100644 index 109892f..0000000 --- a/templates/booking/create_step_2.html.twig +++ /dev/null @@ -1,337 +0,0 @@ -{% extends 'layout.html.twig' %} - -{# Local form theme override for consistent fieldset rendering #} -{% use 'forms.html.twig' %} - -{% block form_row %} - {%- if form.vars.expanded is defined and form.vars.expanded -%} - {# Expanded forms get fieldset wrapper #} -
- - {{- form.vars.label -}} - - {{- form_widget(form, { - 'attr': attr|default({}) - }) -}} - {{- form_errors(form) -}} - {{- form_help(form) -}} -
- {%- else -%} - {{- parent() -}} - {%- endif -%} -{% endblock %} - -{% import _self as macros %} - -{# Macro to render a field or placeholder with consistent fieldset structure #} -{% macro service_field(participant, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} - {% if participant[fieldName] is defined %} - {{ form_row(participant[fieldName], options) }} - {% else %} -
- {{ label }} -
{{ undefinedLabel }}
-
- {% endif %} -{% endmacro %} - -{# Specialized macro for checkbox fields (like rental insurance) that need manual fieldset wrapping #} -{% macro checkbox_field(participant, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} - {% if participant[fieldName] is defined %} -
- {{ label }} - {{ form_row(participant[fieldName], options) }} -
- {% else %} -
- {{ label }} -
{{ undefinedLabel }}
-
- {% endif %} -{% endmacro %} - -{% block content %} - {% include '_partials/_flashes.html.twig' %} -
-

Neue Buchung

-
-
-

Teilnehmer

- {{ form_start(form) }} - {% do form.participants.setRendered %} - {# This block contains the participant form fields #} - {% block participants_form %} -
- {% for participant in form.participants %} - {% set participantDataValid = participant.vars.valid %} - {% set participantData = participant.vars.data %} - {% set hasDateOfBirth = participantData and participantData.dateOfBirth %} - {% set isEligible = hasDateOfBirth and is_participant_eligible(bookingCreateDto, loop.index0) %} -
-
- -
- {{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }} - {% if isEligible and participantPrices is defined and participantPrices[loop.index0] is defined and participantPrices[loop.index0] > 0.0 %} - - €{{ participantPrices[loop.index0]|number_format(2, ',', '.') }} - - {% endif %} -
- -
- -
-
- {% endfor %} -
- {% endblock %} -
- Zurück - -
- {{ form_rest(form) }} - {{ form_end(form) }} -
- {% block booking_summary %} -
- {% include 'booking/_summary.html.twig' with { - 'bookingCreateDto': bookingCreateDto, - 'participantCount': participantsCount, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts - } %} -
- {% endblock %} -
-{% endblock %} diff --git a/templates/booking/edit.html.twig b/templates/booking/edit.html.twig deleted file mode 100644 index 345d1bc..0000000 --- a/templates/booking/edit.html.twig +++ /dev/null @@ -1,394 +0,0 @@ -{% extends 'layout.html.twig' %} - -{# Local form theme override for consistent fieldset rendering #} -{% use 'forms.html.twig' %} - -{% block form_row %} - {%- if form.vars.expanded is defined and form.vars.expanded -%} - {# Expanded forms get fieldset wrapper #} -
- - {{- form.vars.label -}} - - {{- form_widget(form, { - 'attr': attr|default({}) - }) -}} - {{- form_errors(form) -}} - {{- form_help(form) -}} -
- {%- else -%} - {{- parent() -}} - {%- endif -%} -{% endblock %} - -{% import _self as macros %} - -{# Macro to render a field or placeholder with consistent fieldset structure #} -{% macro service_field(participant, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} - {% if participant[fieldName] is defined %} - {{ form_row(participant[fieldName], options) }} - {% else %} -
- {{ label }} -
{{ undefinedLabel }}
-
- {% endif %} -{% endmacro %} - -{# Specialized macro for checkbox fields (like rental insurance) that need manual fieldset wrapping #} -{% macro checkbox_field(participant, fieldName, label, options = {}, undefinedLabel = 'Nicht wählbar') %} - {% if participant[fieldName] is defined %} -
- {{ label }} - {{ form_row(participant[fieldName], options) }} -
- {% else %} -
- {{ label }} -
{{ undefinedLabel }}
-
- {% endif %} -{% endmacro %} - -{% block content %} - {% include '_partials/_flashes.html.twig' %} -
- - {# Header with reload button #} -
-

Buchung bearbeiten

- - -
- - {# Grid layout with 2/3 form + 1/3 summary #} -
-
-

Teilnehmer

- {{ form_start(form) }} - {% do form.participants.setRendered %} - {# This block contains the participant form fields #} - {% block participants_form %} -
- {% for participant in form.participants %} - {% set participantDataValid = participant.vars.valid %} - {% set participantData = participant.vars.data %} - {% set hasDateOfBirth = participantData and participantData.dateOfBirth %} - {% set isEligible = hasDateOfBirth and is_participant_eligible(bookingEditDto, loop.index0) %} - {% set isCanceled = participantData.status == 'S' %} - -
-
- -
- {{ loop.index == 1 ? 'Anmelder:in' : 'Teilnehmer:in ' ~ loop.index }} - {% if isCanceled %} - storniert - {% endif %} - {% if isEligible and not isCanceled and participantPrices is defined and participantPrices[loop.index0] is defined and participantPrices[loop.index0] > 0.0 %} - - €{{ participantPrices[loop.index0]|number_format(2, ',', '.') }} - - {% endif %} -
- -
- -
-
- {% endfor %} -
- {% endblock %} -
- Zurück - -
- {{ form_rest(form) }} - {{ form_end(form) }} -
- {% block booking_summary %} -
- {% include 'booking/_summary.html.twig' with { - 'bookingCreateDto': bookingEditDto, - 'participantCount': participantCount, - 'groupedSelectedRooms': groupedSelectedRooms, - 'assignmentCounts': assignmentCounts, - 'mutableData': mutableData|default(null) - } %} -
- {% endblock %} -
-{% endblock %} diff --git a/templates/booking/edit/index.html.twig b/templates/booking/edit/index.html.twig new file mode 100644 index 0000000..22eb0c1 --- /dev/null +++ b/templates/booking/edit/index.html.twig @@ -0,0 +1,115 @@ +{% extends 'layout.html.twig' %} + +{% block content %} + {% include '_partials/_flashes.html.twig' %} +
+ + {# Header with reload button #} +
+

Buchung bearbeiten

+ + +
+ + {# Grid layout with 2/3 cards + 1/3 summary #} +
+ {# Main content area - cards grid #} +
+ {% block participant_cards %} + {{ form_start(form) }} +
+

Teilnehmer

+ + {% if isDirty %} +
+
+ + + +
+

Ungespeicherte Änderungen

+

+ Du hast Änderungen an der Buchung vorgenommen. + Bitte denke daran, abschließend den 'Buchung aktualisieren' Button zu klicken. +

+
+
+
+ {% endif %} + + {# Display validation errors #} + {% if hasValidationErrors|default(false) %} +
+
+ + + +
+

Bitte überprüfe die Teilnehmerdaten

+

Einige Teilnehmer haben noch unvollständige oder fehlerhafte Angaben. Bitte bearbeite die markierten Teilnehmer.

+
+
+
+ {% endif %} + +
+ {% for participant in bookingDto.participants %} + {% set isCanceled = (bookingData.participantsStatus[loop.index0] ?? null) == 'S' %} + {% set hasErrors = loop.index0 in participantErrors|default([]) %} + {% include 'booking/_participant_card.html.twig' with { + 'cardData': cardsData[loop.index0], + 'index': loop.index0, + 'mode': 'edit', + 'isCanceled': isCanceled, + 'hasErrors': hasErrors, + 'bookingId': bookingData.id + } %} + {% endfor %} +
+ +
+ + {% if isDirty %} + + {% endif %} +
+
+ {{ form_rest(form) }} + {{ form_end(form) }} + {% endblock %} +
+ + {# Sidebar summary #} + {% block booking_summary %} +
+ {% include 'booking/_summary.html.twig' with { + 'bookingCreateDto': bookingDto, + 'participantCount': participantsCount, + 'pricingData': pricingData, + 'groupedSelectedRooms': groupedSelectedRooms, + 'assignmentCounts': assignmentCounts, + 'mutableData': mutableData|default(null) + } %} +
+ {% endblock %} +
+{% endblock %}