# 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`