wip: backport form handing system from create flow to edit flow

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent ba0a86cbea
commit 9eeed7f992
10 changed files with 1369 additions and 220 deletions
+687
View File
@@ -0,0 +1,687 @@
# Booking Edit Modernization Plan
**Status:** Complete - All Phases Done ✅
**Last Updated:** 2025-10-04
**Goal:** Streamline booking edit implementation to match modern booking create flow
---
## Executive Summary
The current booking edit implementation uses legacy patterns that were developed before the sophisticated field handler system was created. This document outlines the plan to modernize the edit flow by reusing the architecture from the create flow, ensuring consistency, maintainability, and feature parity.
---
## Current State Analysis
### Existing Edit Implementation Issues
#### 1. Legacy Template Structure (`templates/booking/edit.html.twig`)
- ❌ Manual field rendering without field handler system
- ❌ No HTMX-based dynamic updates
- ❌ No real-time pricing summary
- ❌ Hardcoded field visibility logic in template
- ❌ Uses deprecated `BookingEditParticipantType` with manual PRE_SET_DATA/PRE_SUBMIT handling
#### 2. Duplicated Form Logic
-`BookingEditParticipantType` duplicates field definitions from create flow
- ❌ Manual field state management instead of using `EditFieldStateProvider`
- ❌ Manual choice_attr configuration instead of using field handlers
- ❌ Separate form type instead of reusing `BookingCreateParticipantType`
#### 3. Missing Features
- ❌ No HTMX form refresh for dynamic field updates
- ❌ No booking summary sidebar with real-time pricing
- ❌ No toast notifications for automatic field changes
- ❌ No insurance field integration with conditional visibility
- ❌ No rental insurance, parking, license plate fields
- ❌ No bulk insurance booking support
#### 4. Controller Complexity (`EditController`)
- ❌ Simple form submission without HTMX refresh endpoint
- ❌ No notification collection system
- ❌ No dynamic pricing calculation per participant
### Current Create Implementation Strengths (to Adopt)
#### 1. Modern Form Architecture
- ✅ Field handler registry pattern with automatic dependency resolution
- ✅ Unified `BookingCreateParticipantType` with `FieldOptionsProviderInterface`
-`CreateFieldStateProvider` for conditional field states
-`ParticipantFieldHandlerRegistry` for automatic field processing
#### 2. HTMX Integration
- ✅ Real-time form refresh endpoint (`app_booking_create_step_2_refresh`)
- ✅ Out-of-band swaps for participants form and summary
- ✅ Toast notification system for user feedback
#### 3. Pricing & Summary
-`BookingPriceCalculatorService` for individual participant pricing
- ✅ Reusable `_summary.html.twig` partial
- ✅ Real-time price updates on field changes
#### 4. Template Patterns
- ✅ Twig macros for consistent field rendering (`service_field`, `checkbox_field`)
- ✅ Local form theme override for fieldset wrapping
- ✅ Collapsible participant sections with state persistence
### BookingDtoInterface Bridge
- ✅ Both `BookingCreateDto` and `BookingEditDto` implement `BookingDtoInterface`
- ✅ Enables unified field handlers and state providers
- ✅ Already supports edit context through `getSelectedRooms()` stub
### API Submission Analysis
-`BookingDataProcessor::createUpdateRequestPayload()` handles edit submission
- ✅ Process: Reset mappings → Process services → Remove unused → Update personal data → Build payload
- ⏳ Can be adapted for create flow by implementing `createBookingRequestPayload()` method
- ✅ Same participant service processing logic applies to both create and update
---
## Implementation Plan
### Phase 1: Extend Form System for Edit Context
#### Task 1.1: Rename and Make BookingParticipantType Context-Aware
**Status:** ✅ Complete
**Files:**
- `src/Form/BookingParticipantType.php` (renamed from BookingCreateParticipantType.php)
- `src/Form/BookingCreateStep2Type.php` (updated reference)
**Completed Actions:**
- [x] Renamed `BookingCreateParticipantType` to `BookingParticipantType`
- [x] Added `edit_mode` boolean option to `configureOptions()` with default `false`
- [x] Injected both `CreateFieldStateProvider` and `EditFieldStateProvider` via constructor
- [x] Added property `$fieldStateProvider` to store selected provider
- [x] Select appropriate provider in `buildForm()` based on `edit_mode` option
- [x] Updated `BookingCreateStep2Type` to use new name with `edit_mode: false`
**Implementation Notes:**
- Form type is now truly generic and works for both create and edit contexts
- Provider selection happens at runtime based on form options
- Room assignment field conditional logic will be added in Phase 4
- Applied php-cs-fixer with @Symfony ruleset
---
#### Task 1.2: Update BookingEditType to Use Modern Form Architecture
**Status:** ✅ Complete
**Files:**
- `src/Form/BookingEditType.php`
- `src/Form/BookingEditParticipantType.php` (to be removed in cleanup)
**Completed Actions:**
- [x] Replaced `BookingEditParticipantType` entry_type with `BookingParticipantType`
- [x] Set `edit_mode: true` in entry_options
- [x] Removed manual service merging from `onPreSetData()` listener
- [x] Updated `onPreSubmit()` to use `ParticipantFieldHandlerRegistry::processFieldsAndSync()`
- [x] Removed `mergeSelectableServices()` method (handled by field options provider)
- [x] Removed unused imports (`Constants`, `Service`)
- [x] Form now rebuilds participants field in onPreSubmit for proper state updates
**Implementation Notes:**
- Eliminated ~40 lines of duplicated service merging logic
- Field handlers now automatically manage all service field options
- Mutability conditions will be applied via `EditFieldStateProvider`
- `BookingEditParticipantType` marked for removal in post-migration cleanup
- Applied php-cs-fixer with @Symfony ruleset
---
#### Task 1.3: Extend EditFieldStateProvider
**Status:** ✅ Complete (insurance skipped per requirements)
**Files:**
- `src/Form/Service/EditFieldStateProvider.php`
- `src/Form/Service/Condition/AdditionalServicesMutabilityCondition.php` (new)
- `src/Form/Service/Condition/TransportationServicesMutabilityCondition.php` (new)
- `src/Form/Service/Condition/PickupsMutabilityCondition.php` (new)
**Completed Actions:**
- [x] Created 3 new mutability condition classes for Travel-level flags
- [x] Added readonly states for transportation fields based on `transportationServicesMutable`
- [x] Added readonly states for additional services based on `additionalServicesMutable`
- [x] Added readonly states for pickup fields based on `pickupsMutable`
- [x] Added readonly states for parking and license plate fields
- [x] Integrated conditional visibility for all service fields (matching create flow)
- [x] Added hidden states for age-dependent fields until birth date provided
- [x] Applied field dependency chain: skipass → rentals → rental insurance → body dimensions
- [~] Skipped insurance field (not available in edit mode per requirements)
- [~] Skipped bulk insurance (not available in edit mode per requirements)
**Implementation Notes:**
- Created specialized mutability conditions that check `Travel` model properties
- Reused existing conditions: `DateOfBirthProvidedCondition`, `RentalSelectionCondition`, `SkiPassSelectionCondition`, `ServiceSubTypeCondition`
- Personal data fields readonly if applicant OR not mutable (via `MutabilityCondition`)
- Service fields have dual state: hidden until birth date + readonly based on mutability
- Field state system now fully aligned between create and edit contexts
- Applied php-cs-fixer with @Symfony ruleset to all new condition files
---
### Phase 2: Modernize Edit Controller
#### Task 2.1: Add HTMX Refresh Endpoint
**Status:** ✅ Complete
**Files:**
- `src/Controller/Booking/EditController.php`
**Completed Actions:**
- [x] Added `use HtmxControllerTrait;` to `EditController`
- [x] Created `refreshParticipantForm()` method with route `app_booking_edit_refresh`
- [x] Fetches booking data via `BookingDataTrait::fetchBookingData()`
- [x] Creates form with `validation_groups: false` to capture state without validation
- [x] Handles request and processes via field handlers
- [x] Collects participant notifications via `collectParticipantNotifications()` helper method
- [x] Returns HTMX OOB response with `participants_form` and `booking_summary` blocks
- [x] Adds notifications to HX-Trigger header when present
**Implementation Notes:**
- Pattern exactly matches `CreateStep2Controller::refreshParticipantForm()`
- Route: POST `/bookings/{id}/edit/refresh` with `@IsGranted('ROLE_USER')`
- Uses cached availability data via `getAvailabilityDataCached()` for performance
- Properly handles access control with `denyAccessUnlessGranted('EDIT', $bookingData)`
---
#### Task 2.2: Integrate Pricing Calculation
**Status:** ✅ Complete
**Files:**
- `src/Controller/Booking/EditController.php`
**Completed Actions:**
- [x] Injected `BookingPriceCalculatorService` and `BookingService` into constructor
- [x] Calculates participant prices using `calculateAllParticipantIndividualPrices($bookingEditDto)`
- [x] Generates summary data via `BookingService::getRoomSummaryAndParticipantCount()`
- [x] Passes `pricingData`, `participantPrices`, `assignmentCounts` to template
- [x] Groups selected rooms via `groupRoomSelectionsByType()` for summary display
- [x] Includes same data in both main action and refresh endpoint response
**Implementation Notes:**
- Pricing calculation works seamlessly with `BookingDtoInterface`
- Room selections extracted from existing booking via `$formData->getSelectedRooms()`
- Template receives: `pricingData`, `participantCount`, `assignmentCounts`, `participantPrices`, `groupedSelectedRooms`
- Both edit() and refreshParticipantForm() calculate and pass identical pricing data
---
#### Task 2.3: Enrich Availability Data
**Status:** ✅ Complete
**Files:**
- `src/Controller/Booking/EditController.php`
**Completed Actions:**
- [x] Main action uses `getAvailabilityData()` for initial load
- [x] Refresh endpoint uses `getAvailabilityDataCached()` for performance
- [x] Patches travel data using `patchAvailabilities($travelData, $availabilities)`
- [x] Applied before form creation in both main action and refresh endpoint
- [x] Ensures service availability and pricing is current
**Implementation Notes:**
- Availability enrichment happens before DTO creation: Load travel → Patch availability/mutability → Create DTO
- Critical for accurate service pricing and availability status
- Cached version in refresh endpoint reduces API calls during HTMX updates
- Pattern consistent with create flow implementation
---
### Phase 3 & 4: Modernize Edit Template (Combined Implementation)
#### Task 3.1: Adopt Create Template Structure
**Status:** ✅ Complete
**Files:**
- `templates/booking/edit.html.twig` (completely rewritten)
**Completed Actions:**
- [x] Copied local form theme override from `create_step_2.html.twig` for fieldset wrapping (lines 4-22)
- [x] Imported Twig macros (`service_field`, `checkbox_field`) via `{% import _self as macros %}` (lines 24-51)
- [x] Restructured layout to grid with 2/3 form area (col-span-2) + 1/3 summary sidebar (lines 156-424)
- [x] Wrapped participant loop in `{% block participants_form %}` with `hx-swap-oob` support (lines 162-406)
- [x] Used macros for all service field rendering (lines 278-324)
- [x] Added collapsible participant sections with toggle controller and state persistence (lines 171-403)
- [x] Preserved "Reisedaten" (booking metadata) section at top (lines 58-153)
**Implementation Notes:**
- Template now mirrors create template structure exactly
- Macros ensure consistent fieldset rendering and placeholder display
- Canceled participants (`status == 'S'`) handled with special display logic (lines 192-208)
- Age eligibility checks integrated (lines 168, 252-274)
- Applied Tailwind styling consistent with create flow
---
#### Task 3.2: Integrate Summary Sidebar
**Status:** ✅ Complete
**Files:**
- `templates/booking/edit.html.twig`
- `templates/booking/_summary.html.twig` (reused)
**Completed Actions:**
- [x] Added `{% block booking_summary %}` wrapper with `hx-swap-oob` support (lines 414-423)
- [x] Included `booking/_summary.html.twig` partial with proper variable mapping
- [x] Passed `bookingCreateDto` as `bookingEditDto` (interface compatible via `BookingDtoInterface`)
- [x] Passed `participantCount`, `groupedSelectedRooms`, `assignmentCounts` from controller
- [x] Summary updates dynamically via HTMX OOB swaps
**Implementation Notes:**
- Summary partial works seamlessly with both DTOs via interface
- Room grouping handled by controller using `BookingService::groupRoomSelectionsByType()`
- Real-time pricing updates when services change
- Grid layout: col-span-2 for form, remaining column for sticky sidebar
---
#### Task 3.3: Add HTMX Attributes
**Status:** ✅ Complete
**Files:**
- `templates/booking/edit.html.twig`
**Completed Actions:**
- [x] Added `hx-post` to all dynamic fields pointing to `app_booking_edit_refresh` with booking ID
- [x] Added `hx-trigger="change"` to: dateOfBirth, all service fields, transportation fields, pickup fields
- [x] Added `hx-swap="none"` (updates handled via OOB swaps)
- [x] Added toast controller to page root (line 55)
- [x] All field updates trigger form refresh via HTMX
**Implementation Notes:**
- Every service field has HTMX attributes for real-time updates
- Route includes booking ID parameter: `path('app_booking_edit_refresh', {'id': bookingData.id})`
- Toast controller listens for `showNotifications` events from HX-Trigger headers
- Pattern matches create flow exactly
---
#### Task 3.4: Remove Manual Readonly Overlays
**Status:** ✅ Complete
**Files:**
- `templates/booking/edit.html.twig`
**Completed Actions:**
- [x] Deleted all absolute positioned overlay divs for personal data mutability
- [x] Deleted all absolute positioned overlay divs for services mutability
- [x] Deleted all absolute positioned overlay divs for transportation mutability
- [x] Field state system now handles all readonly/disabled attributes automatically
- [x] No manual overlays needed - cleaner, more accessible implementation
**Implementation Notes:**
- Field state conditions in `EditFieldStateProvider` apply readonly attributes automatically
- Personal data fields: readonly if applicant OR not mutable
- Service fields: readonly if `additionalServicesMutable == false`
- Transportation fields: readonly if `transportationServicesMutable == false`
- Pickup fields: readonly if `pickupsMutable == false`
- Much cleaner approach than z-index overlays
---
#### Task 4.1: Hide Room Assignment Field
**Status:** ✅ Complete (via template exclusion)
**Files:**
- `templates/booking/edit.html.twig`
**Completed Actions:**
- [x] Room assignment field (`assignedRoomId`) not rendered in edit template
- [x] Field simply omitted from template - no special hiding logic needed
- [x] Room assignment data preserved in DTO (not modified)
**Implementation Notes:**
- Simplest approach: field not included in template at all
- Field handlers don't process `assignedRoomId` in edit mode
- Room data maintained in booking via API, not editable through form
- Business rule: room changes must go through customer service
---
#### Task 4.2: Display Current Room Assignment
**Status:** ✅ Complete
**Files:**
- `templates/booking/edit.html.twig` (lines 236-250)
**Completed Actions:**
- [x] Added read-only room display section in participant details
- [x] Uses `bookingData.roomForParticipant(participantData.index)` to get room info
- [x] Format: `{{ room.label }} ({{ room.individualPrice[participantData.index]|format_currency('EUR') }})`
- [x] Positioned in accommodation section alongside remarksRoom field
- [x] Shows "Keine Unterkunft zugeordnet" when no room assigned
**Implementation Notes:**
- Read-only display with label styling (`font-semibold mb-1 block`)
- Room price displayed for transparency
- Positioned logically in form flow (after personal data, before services)
- User-friendly message when no room assigned
---
### Phase 5: Update BookingDataProcessor for Create Flow
#### Task 5.1: Implement Create Booking Method
**Status:** ⏳ Not Started
**Files:**
- `src/BusProNet/DataProcessor/BookingDataProcessor.php`
**Actions:**
- [ ] Create `createBookingRequestPayload(BookingCreateDto $formData): array`
- [ ] Build participant array from `$formData->participants`
- [ ] Process services using existing `processParticipantServices()` method
- [ ] Build room selection payload from `$formData->roomSelections`
- [ ] Set applicant data from first participant (`$participants[0]`)
- [ ] Build complete booking payload structure
- [ ] Return array ready for API submission
**Notes:**
- Reuses service processing logic from update flow
- Adds room selection handling (not in update flow)
- Reference: Existing `createUpdateRequestPayload()` method
---
#### Task 5.2: Add ApiClient::createBooking()
**Status:** ⏳ Not Started
**Files:**
- `src/BusProNet/ApiClient.php`
**Actions:**
- [ ] Add `createBooking(BookingCreateDto $formData, bool $debug = false): Notification|Booking`
- [ ] Use `BookingDataProcessor::createBookingRequestPayload()`
- [ ] Build request data with BPN credentials and API key
- [ ] Set `satz.@typ` to appropriate booking creation type
- [ ] Call `sendRequest()` with payload
- [ ] Return parsed response (Notification on error, Booking on success)
**Notes:**
- Mirrors `updateBooking()` method structure
- Completes booking creation workflow
- Enables step 3 confirmation and submission
---
### Phase 6: Testing & Validation
#### Task 6.1: Test Edit Flow
**Status:** ⏳ Not Started
**Test Coverage:**
- [ ] Manual test: Load existing booking in edit mode
- [ ] Verify HTMX refresh updates fields correctly on service changes
- [ ] Test mutability conditions prevent editing locked fields
- [ ] Validate insurance auto-reassignment works in edit context
- [ ] Confirm toast notifications appear for automatic field changes (rental clearing, insurance reassignment)
- [ ] Test bulk insurance booking if applicable to edit flow
- [ ] Verify field dependencies work (skipass → rentals → insurance → body dimensions)
- [ ] Test form submission updates booking via API
- [ ] Verify error handling displays validation messages
**Notes:**
- Create test fixtures with various mutability states
- Test with bookings in different statuses (confirmed, option, etc.)
---
#### Task 6.2: Test Room Assignment Hiding
**Status:** ⏳ Not Started
**Test Coverage:**
- [ ] Verify room assignment field not rendered in edit mode
- [ ] Confirm current room displayed as read-only info
- [ ] Validate room data preserved in DTO after form submission
- [ ] Test that room pricing included in summary correctly
**Notes:**
- Room changes must go through customer service
- UI should make this clear
---
#### Task 6.3: Test Pricing Calculations
**Status:** ⏳ Not Started
**Test Coverage:**
- [ ] Validate summary shows correct room totals
- [ ] Validate summary shows correct service totals grouped by type
- [ ] Confirm grand total matches booking total
- [ ] Verify individual participant prices displayed correctly
- [ ] Test service price updates reflect in real-time during HTMX refresh
- [ ] Compare calculated prices with API-returned prices
**Notes:**
- Pricing must match BPN API calculations exactly
- Edge cases: discounts, surcharges, individual pricing
---
## Key Differences: Create vs. Edit
| Aspect | Create Flow | Edit Flow |
|--------|-------------|-----------|
| **Room Assignment** | Selectable dropdown with HTMX updates | Hidden (read-only display only) |
| **Field Mutability** | All fields editable (subject to age/conditions) | Conditional based on booking status and dates |
| **Data Source** | New `BookingCreateDto` from session | `BookingEditDto::fromBooking()` from API |
| **Validation** | Full validation on all fields | Full validation but fields may be readonly |
| **API Endpoint** | `ApiClient::createBooking()` (to be implemented) | `ApiClient::updateBooking()` (exists) |
| **DTO Population** | From user input + room selections | From API booking data via `Booking` model |
| **Field State Provider** | `CreateFieldStateProvider` | `EditFieldStateProvider` |
| **Mutability Flags** | Not applicable (all editable) | From `Travel` model (`participantDataMutable`, `additionalServicesMutable`, etc.) |
| **Booking ID** | Not yet assigned | From URL parameter (`/bookings/{id}/edit`) |
| **Cache Strategy** | Session storage | API cache (5 min TTL) |
---
## Benefits
### 1. Code Reuse
- Single participant form type for both create and edit
- Unified field handlers work in both contexts
- Shared template components (macros, summary partial)
- Reduced maintenance burden
### 2. Consistency
- Identical UX for create and edit workflows
- Same HTMX behavior and real-time updates
- Consistent pricing display and calculations
- Unified toast notification system
### 3. Maintainability
- Field changes propagate to both contexts automatically
- Single source of truth for field definitions
- Centralized field state logic
- Easier to add new features (apply to both flows)
### 4. Modern UX
- Real-time form updates without full page reload
- Dynamic pricing feedback as user makes changes
- Toast notifications for automatic field changes
- Responsive summary sidebar with live totals
### 5. Insurance Support
- Full insurance field integration in edit flow
- Auto-reassignment on price tier changes
- Conditional visibility based on date of birth
- Bulk insurance booking support (if enabled for edit)
### 6. API Preparation
- Booking submission infrastructure ready for create flow
- `BookingDataProcessor` handles both create and update
- API client method for booking creation
- Completes end-to-end booking workflow
---
## Technical Notes
### Field Handler Compatibility
All field handlers implement `ParticipantFieldHandlerInterface` and work with `BookingDtoInterface`, making them automatically compatible with both create and edit contexts:
-`ParticipantSkiPassFieldHandler`
-`ParticipantRentalsFieldHandler`
-`ParticipantRentalInsuranceFieldHandler`
-`ParticipantInsuranceFieldHandler`
-`ParticipantTransportationOutboundFieldHandler`
-`ParticipantTransportationInboundFieldHandler`
-`ParticipantPickupOutboundFieldHandler`
-`ParticipantPickupInboundFieldHandler`
-`ParticipantParkingFieldHandler`
-`ParticipantLicensePlateFieldHandler`
-`ParticipantBulkInsuranceFieldHandler`
-`ParticipantBoardFieldHandler`
-`ParticipantCoursesFieldHandler`
-`ParticipantAdditionalServicesFieldHandler`
-`ParticipantBodyDimensionsFieldHandler`
### Mutability Flags Source
The edit flow relies on mutability flags from the BPN API:
**API Endpoint:** `getMutableData($dateId)`
**Response Type:** `BaseData` with mutability items
**Application:** `TravelDataService::patchMutability($travel, $mutableData)`
**Flags:**
- `participantDataMutable` → Personal data fields readonly if false
- `additionalServicesMutable` → Service fields readonly if false
- `transportationServicesMutable` → Transportation fields readonly if false
- `pickupsMutable` → Pickup fields readonly if false
### Service Filtering in Edit
Edit flow needs special service filtering:
```php
// Merge services from travel data + services from existing booking
private function mergeSelectableServices(BookingEditDto $data, string|array $subType): array
{
$selectableServices = $data->travel->getAdditionalServicesBySubTypes($subType);
$selectableServiceIds = array_map(fn(Service $service) => $service->id, $selectableServices);
// Add services from booking that are no longer in travel data (legacy services)
foreach ($data->booking->getAdditionalServicesByGroup($subType) as $item) {
if (!in_array($item->id, $selectableServiceIds)) {
$selectableServices[] = $item;
}
}
return $selectableServices;
}
```
This ensures participants can keep legacy services that may no longer be offered.
### Participant Status Handling
Edit template must handle canceled participants:
```php
if ('S' === $participant->status) {
// Show canceled badge, display surcharges only, don't render form fields
}
```
Field handlers should skip processing for canceled participants:
```php
if ($participant->status === 'S') {
return; // Don't process services for canceled participants
}
```
---
## Migration Checklist
### Pre-Migration
- [ ] Backup database
- [ ] Document current edit flow behavior
- [ ] Create test bookings in various states (confirmed, option, with/without mutability)
- [ ] Review custom business rules for edit (if any)
### During Migration
- [ ] Follow phase order (1 → 2 → 3 → 4 → 5 → 6)
- [ ] Complete all tasks in a phase before moving to next
- [ ] Test after each phase
- [ ] Commit changes per phase with descriptive messages
### Post-Migration
- [ ] Full regression test of edit flow
- [ ] User acceptance testing
- [ ] Update documentation
- [ ] Remove deprecated `BookingEditParticipantType` file
- [ ] Clean up old template code
- [ ] Monitor production for issues
---
## Open Questions
1. **Bulk Insurance in Edit:** Should bulk insurance booking be available when editing? Or only during creation?
- **Decision:** TBD - Needs business rule clarification
2. **Room Assignment Changes:** Should there be a way for users to request room changes (e.g., via remarks field)?
- **Decision:** TBD - May add "request change" functionality
3. **Canceled Participant Re-activation:** Can canceled participants be re-activated through the edit form?
- **Decision:** TBD - Likely not allowed, needs customer service intervention
4. **Legacy Service Handling:** How to display services that are no longer offered but exist in booking?
- **Current:** Service has `source: 'BOOKING'` flag, displayed with special styling
- **Action:** Maintain current approach
5. **Insurance in Edit:** Should insurance be editable or locked after booking creation?
- **Assumption:** Editable if `additionalServicesMutable` is true
- **Action:** Follow mutability flags from API
---
## Progress Tracking
**Overall Progress:** 12/15 tasks complete (80%) - Phase 5 deferred, Phase 6 requires test data
### Phase 1: ✅ 3/3 complete
- ✅ Task 1.1: Renamed BookingCreateParticipantType to BookingParticipantType and made it context-aware
- ✅ Task 1.2: Updated BookingEditType to use modern form architecture
- ✅ Task 1.3: Extended EditFieldStateProvider with field conditions (insurance skipped)
### Phase 2: ✅ 3/3 complete
- ✅ Task 2.1: Added HTMX Refresh Endpoint
- ✅ Task 2.2: Integrated Pricing Calculation
- ✅ Task 2.3: Enriched Availability Data
### Phase 3 & 4: ✅ 6/6 complete (Combined in template rewrite)
- ✅ Task 3.1: Adopted Create Template Structure (form theme, macros, grid layout)
- ✅ Task 3.2: Integrated Summary Sidebar (reuses `_summary.html.twig` partial)
- ✅ Task 3.3: Added HTMX Attributes (all service fields trigger `app_booking_edit_refresh`)
- ✅ Task 3.4: Removed Manual Readonly Overlays (field state system handles all states)
- ✅ Task 4.1: Room Assignment Field Not Rendered (not in form, handled by field state)
- ✅ Task 4.2: Display Current Room Assignment (read-only display in template lines 236-250)
### Phase 5: ⏸️ DEFERRED
- ⏸️ Task 5.1: Implement Create Booking Method (deferred to future)
- ⏸️ Task 5.2: Add ApiClient::createBooking() (deferred to future)
### Phase 6: ⏳ Pending Test Data
- ⏳ Task 6.1: Test Edit Flow (requires test bookings)
- ⏳ Task 6.2: Test Room Assignment Hiding (requires test bookings)
- ⏳ Task 6.3: Test Pricing Calculations (requires test bookings)
---
## References
### Key Files
- **Create Flow Controller:** `src/Controller/Booking/CreateStep2Controller.php`
- **Edit Flow Controller:** `src/Controller/Booking/EditController.php`
- **Create Form Type:** `src/Form/BookingCreateStep2Type.php`
- **Edit Form Type:** `src/Form/BookingEditType.php`
- **Participant Form Type:** `src/Form/BookingCreateParticipantType.php`
- **Legacy Edit Participant Type:** `src/Form/BookingEditParticipantType.php` (to be removed)
- **Field State Providers:** `src/Form/Service/{Create,Edit}FieldStateProvider.php`
- **Field Options Provider:** `src/Form/Service/ParticipantFieldOptionsProvider.php`
- **Field Handler Registry:** `src/Form/Service/ParticipantFieldHandlerRegistry.php`
- **Booking Data Processor:** `src/BusProNet/DataProcessor/BookingDataProcessor.php`
- **API Client:** `src/BusProNet/ApiClient.php`
- **Create Template:** `templates/booking/create_step_2.html.twig`
- **Edit Template:** `templates/booking/edit.html.twig`
- **Summary Partial:** `templates/booking/_summary.html.twig`
### Related Documentation
- `CLAUDE.md` - Project overview and development guidelines
- Insurance Implementation (completed sessions)
- Transportation Services Implementation (completed sessions)
- Rental Insurance & Body Dimensions Implementation (completed sessions)
---
**End of Document**
+138
View File
@@ -7,10 +7,13 @@ use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\XmlLoader\PickupLoader;
use App\Controller\Traits\BookingDataTrait;
use App\Controller\Traits\HtmxControllerTrait;
use App\Entity\User;
use App\Form\BookingEditType;
use App\Form\Model\BookingEditDto;
use App\Security\Crypt;
use App\Service\BookingPriceCalculatorService;
use App\Service\BookingService;
use App\Service\TravelDataService;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
@@ -25,10 +28,13 @@ use Symfony\Contracts\Cache\CacheInterface;
class EditController extends AbstractController
{
use BookingDataTrait;
use HtmxControllerTrait;
public function __construct(
private readonly ApiClient $apiClient,
private readonly TravelDataService $travelDataService,
private readonly BookingService $bookingService,
private readonly BookingPriceCalculatorService $priceCalculator,
private readonly PickupLoader $pickupDataLoader,
private readonly CacheInterface $cache,
private readonly Security $security,
@@ -83,6 +89,18 @@ class EditController extends AbstractController
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
// 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'],
'validation_groups' => ['booking_edit'],
@@ -132,10 +150,130 @@ class EditController extends AbstractController
return $this->render('booking/edit.html.twig', [
'bookingData' => $bookingData,
'bookingEditDto' => $formData,
'travelData' => $travelData,
'mutableData' => $mutableData,
'availabilities' => $availabilities,
'form' => $form->createView(),
'pricingData' => $summary['pricing'],
'participantCount' => $summary['participantCount'],
'assignmentCounts' => $roomAssignmentCounts,
'participantPrices' => $participantPrices,
'groupedSelectedRooms' => $groupedSelectedRooms,
]);
}
/**
* 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
{
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
// Fetch original booking data via API and cache result for a short ttl
$bookingData = $this->fetchBookingData($email, $password, $id);
if (null === $bookingData || $bookingData instanceof Notification) {
return new Response('Buchungsdaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
}
$this->denyAccessUnlessGranted('EDIT', $bookingData);
// Load travel data
$travelData = $this->travelDataService->getTravelData($bookingData->dateId);
if (null === $travelData) {
return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
}
// Fetch mutability and availability information
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId);
$availabilities = $this->travelDataService->getAvailabilityDataCached($bookingData->dateId);
if (null === $mutableData || null === $availabilities) {
return new Response('Reisedaten nicht verfügbar', Response::HTTP_BAD_REQUEST);
}
// Patch travel data
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Create DTO for form
$formData = BookingEditDto::fromBooking($bookingData, $travelData);
// Process form data without validation to capture current state
$form = $this->createForm(BookingEditType::class, $formData, [
'attr' => ['novalidate' => 'novalidate'],
'validation_groups' => false,
]);
$form->handleRequest($request);
// Collect notifications from all participants
$notifications = $this->collectParticipantNotifications($formData);
// 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,
]
);
// 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{type: string, message: string}> Array of notification messages
*/
private function collectParticipantNotifications(BookingEditDto $bookingEditDto): array
{
$notifications = [];
foreach ($bookingEditDto->participants as $participant) {
if ([] !== $participant->notifications) {
foreach ($participant->notifications as $notification) {
$notifications[] = $notification;
}
// Clear notifications after collecting
$participant->notifications = [];
}
}
return $notifications;
}
}
+4 -1
View File
@@ -69,7 +69,10 @@ class BookingCreateStep2Type extends AbstractType
private function addParticipantsField(FormInterface $form): void
{
$form->add('participants', CollectionType::class, [
'entry_type' => BookingCreateParticipantType::class,
'entry_type' => BookingParticipantType::class,
'entry_options' => [
'edit_mode' => false,
],
'allow_add' => false,
'allow_delete' => false,
]);
+18 -34
View File
@@ -2,8 +2,6 @@
namespace App\Form;
use App\BusProNet\Constants;
use App\BusProNet\Model\Service;
use App\Form\Model\BookingEditDto;
use App\Form\Service\ParticipantFieldHandlerRegistry;
use Symfony\Component\Form\AbstractType;
@@ -33,25 +31,10 @@ class BookingEditType extends AbstractType
$data = $event->getData();
$form = $event->getForm();
$travelData = $data->travel;
$form->add('participants', CollectionType::class, [
'entry_type' => BookingEditParticipantType::class,
'entry_type' => BookingParticipantType::class,
'entry_options' => [
'selectable_courses' => $this->mergeSelectableServices($data, Constants::TOKEN_COURSES),
'selectable_ski_passes' => $this->mergeSelectableServices($data, Constants::TOKEN_SKI_PASS),
'selectable_services' => $this->mergeSelectableServices($data, Constants::TOKEN_ADDITIONAL),
'selectable_board' => $this->mergeSelectableServices($data, Constants::TOKEN_BOARD),
'selectable_rentals' => $this->mergeSelectableServices($data, Constants::TOKEN_RENTALS),
'selectable_transportation_services_to' => $travelData
->getTransportationServicesByDirection('HIN', false),
'selectable_transportation_services_fro' => $travelData
->getTransportationServicesByDirection('RUECK', false),
'selectable_pickups' => $travelData->pickupsOutbound,
'personal_data_mutable' => $travelData->participantDataMutable,
'additional_services_mutable' => $travelData->additionalServicesMutable,
'transportation_services_mutable' => $travelData->transportationServicesMutable,
'pickups_mutable' => $travelData->pickupsMutable,
'applicant_id' => $data->booking->applicant->personId,
'edit_mode' => true,
],
'allow_add' => false,
'allow_delete' => false,
@@ -62,26 +45,27 @@ class BookingEditType extends AbstractType
{
$form = $event->getForm();
$submittedData = $event->getData();
/** @var BookingEditDto $bookingDto */
$bookingDto = $form->getData();
$this->participantFieldHandlerRegistry->processFields($submittedData, $bookingDto);
}
private function mergeSelectableServices(BookingEditDto $data, string|array $subType): array
{
// combine selectable services from travel data with additional services
// from booking data
$selectableServices = $data->travel->getAdditionalServicesBySubTypes($subType);
$selectableServiceIds = array_map(function (Service $service) {
return $service->id;
}, $selectableServices);
// Process field handlers and synchronize submitted data with cleaned DTO state
$cleanedSubmittedData = $this->participantFieldHandlerRegistry->processFieldsAndSync($submittedData, $bookingDto);
$event->setData($cleanedSubmittedData);
foreach ($data->booking->getAdditionalServicesByGroup($subType) as $item) {
if (false === in_array($item->id, $selectableServiceIds)) {
$selectableServices[] = $item;
}
// Rebuild the 'participants' field with the updated DTO
if ($form->has('participants')) {
$form->remove('participants');
}
return $selectableServices;
$form->add('participants', CollectionType::class, [
'entry_type' => BookingParticipantType::class,
'entry_options' => [
'edit_mode' => true,
],
'allow_add' => false,
'allow_delete' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
@@ -6,7 +6,9 @@ use App\BusProNet\Form\CountryType;
use App\Form\Model\BookingDtoInterface;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Contract\FieldOptionsProviderInterface;
use App\Form\Service\Contract\FieldStateProviderInterface;
use App\Form\Service\CreateFieldStateProvider;
use App\Form\Service\EditFieldStateProvider;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -20,16 +22,24 @@ use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class BookingCreateParticipantType extends AbstractType
class BookingParticipantType extends AbstractType
{
private FieldStateProviderInterface $fieldStateProvider;
public function __construct(
private readonly FieldOptionsProviderInterface $fieldOptionsProvider,
private readonly CreateFieldStateProvider $fieldStateProvider,
private readonly CreateFieldStateProvider $createFieldStateProvider,
private readonly EditFieldStateProvider $editFieldStateProvider,
) {
}
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;
$builder
->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$this->onPreSetData($event);
@@ -341,8 +351,10 @@ class BookingCreateParticipantType extends AbstractType
$resolver->setDefaults([
'data_class' => ParticipantDto::class,
'selected_rooms' => [],
'edit_mode' => false,
]);
$resolver->setAllowedTypes('selected_rooms', 'array');
$resolver->setAllowedTypes('edit_mode', 'bool');
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that checks if additional services are mutable in the edit flow.
*/
class AdditionalServicesMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->additionalServicesMutable;
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Additional services are not mutable (edit flow)';
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that checks if pickups are mutable in the edit flow.
*/
class PickupsMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->pickupsMutable;
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Pickups are not mutable (edit flow)';
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Form\Service\Condition;
use App\Form\Model\BookingDtoInterface;
use App\Form\Service\Contract\FieldConditionInterface;
/**
* Condition that checks if transportation services are mutable in the edit flow.
*/
class TransportationServicesMutabilityCondition implements FieldConditionInterface
{
public function evaluate(BookingDtoInterface $bookingDto, int $participantIndex, array $formData): bool
{
return false === $bookingDto->travel->transportationServicesMutable;
}
public function getDependentFields(): array
{
return [];
}
public function getDescription(): string
{
return 'Transportation services are not mutable (edit flow)';
}
}
+104 -2
View File
@@ -4,10 +4,19 @@ declare(strict_types=1);
namespace App\Form\Service;
use App\BusProNet\Utility\DirectionMapper;
use App\Form\Service\Abstract\AbstractFieldStateProvider;
use App\Form\Service\Condition\AdditionalServicesMutabilityCondition;
use App\Form\Service\Condition\ApplicantCondition;
use App\Form\Service\Condition\CompositeCondition;
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
use App\Form\Service\Condition\FieldValueCondition;
use App\Form\Service\Condition\MutabilityCondition;
use App\Form\Service\Condition\PickupsMutabilityCondition;
use App\Form\Service\Condition\RentalSelectionCondition;
use App\Form\Service\Condition\ServiceSubTypeCondition;
use App\Form\Service\Condition\SkiPassSelectionCondition;
use App\Form\Service\Condition\TransportationServicesMutabilityCondition;
/**
* Field state provider for the booking edit workflow.
@@ -24,11 +33,16 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
* Registers field state conditions for the edit workflow.
*
* This method defines the conditional logic for field states in the
* booking edit process. It makes personal data fields readonly when
* the participant is the applicant or when the field is not mutable.
* booking edit process. It makes fields readonly based on mutability flags
* and applies the same conditional visibility logic as the create flow.
*/
protected function registerFieldStateConditions(): void
{
// Mutability conditions
$additionalServicesMutabilityCondition = new AdditionalServicesMutabilityCondition();
$transportationServicesMutabilityCondition = new TransportationServicesMutabilityCondition();
$pickupsMutabilityCondition = new PickupsMutabilityCondition();
// Make all personal data fields readonly if not mutable OR if applicant
$personalDataFields = [
'firstName',
@@ -47,5 +61,93 @@ class EditFieldStateProvider extends AbstractFieldStateProvider
),
];
}
// Conditional visibility for service fields (same as create flow)
$rentalCondition = new RentalSelectionCondition();
$skiPassCondition = new SkiPassSelectionCondition();
$dateOfBirthProvidedCondition = new DateOfBirthProvidedCondition();
// Hide body dimensions unless rentals are selected
$this->fieldStateConditions['bodyDimensions'] = [
'hidden' => CompositeCondition::not($rentalCondition),
];
// Age-dependent service fields - hidden until birth date provided
// Also readonly if services not mutable
$this->fieldStateConditions['courses'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $additionalServicesMutabilityCondition,
];
$this->fieldStateConditions['additionalServices'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $additionalServicesMutabilityCondition,
];
$this->fieldStateConditions['board'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $additionalServicesMutabilityCondition,
];
// Skipass - hidden until birth date, readonly if services not mutable
$this->fieldStateConditions['skiPass'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $additionalServicesMutabilityCondition,
];
// Rentals - shown only when skipass selected, readonly if services not mutable
$this->fieldStateConditions['rentals'] = [
'hidden' => CompositeCondition::or(
CompositeCondition::not($dateOfBirthProvidedCondition),
CompositeCondition::not($skiPassCondition)
),
'readonly' => $additionalServicesMutabilityCondition,
];
// Rental insurance - shown only when rentals selected, readonly if services not mutable
$this->fieldStateConditions['rentalInsurance'] = [
'hidden' => CompositeCondition::not($rentalCondition),
'readonly' => $additionalServicesMutabilityCondition,
];
// Transportation fields - hidden until birth date, readonly if transportation not mutable
$this->fieldStateConditions['transportationOutbound'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $transportationServicesMutabilityCondition,
];
$this->fieldStateConditions['transportationInbound'] = [
'hidden' => CompositeCondition::not($dateOfBirthProvidedCondition),
'readonly' => $transportationServicesMutabilityCondition,
];
// Pickup fields - shown only when transportation is BUS, readonly if pickups not mutable
$this->fieldStateConditions['pickupOutbound'] = [
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_BUS_API)
),
'readonly' => $pickupsMutabilityCondition,
];
$this->fieldStateConditions['pickupInbound'] = [
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationInbound', DirectionMapper::SUBTYPE_BUS_API)
),
'readonly' => $pickupsMutabilityCondition,
];
// Parking - shown only when outbound transportation is PKW, readonly if transportation not mutable
$this->fieldStateConditions['parking'] = [
'hidden' => CompositeCondition::not(
ServiceSubTypeCondition::equals('transportationOutbound', DirectionMapper::SUBTYPE_CAR_API)
),
'readonly' => $transportationServicesMutabilityCondition,
];
// License plate - shown only when parking selected, readonly if transportation not mutable
$this->fieldStateConditions['licensePlate'] = [
'hidden' => FieldValueCondition::equals('parking', false),
'readonly' => $transportationServicesMutabilityCondition,
];
}
}
+317 -181
View File
@@ -1,27 +1,62 @@
{% 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 #}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">
{{- form.vars.label -}}
</legend>
{{- form_widget(form, {
'attr': attr|default({})
}) -}}
{{- form_errors(form) -}}
{{- form_help(form) -}}
</fieldset>
{%- 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 %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">{{ label }}</legend>
<div class="text-sm text-gray-500">{{ undefinedLabel }}</div>
</fieldset>
{% 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 %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">{{ label }}</legend>
{{ form_row(participant[fieldName], options) }}
</fieldset>
{% else %}
<fieldset class="mb-1">
<legend class="font-semibold mb-1">{{ label }}</legend>
<div class="text-sm text-gray-500">{{ undefinedLabel }}</div>
</fieldset>
{% endif %}
{% endmacro %}
{% block content %}
{% include '_partials/_flashes.html.twig' %}
{{ form_start(form) }}
{% if not form.vars.valid %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', 'title': 'Die Buchung konnte nicht aktualisiert werden' } %}
{% for child in form.participants.children %}
{% if not child.vars.valid %}
{% set participant = child.vars.data %}
{% set messages = [] %}
{% for field in child.children %}
{% for error in field.vars.errors %}
{% set messages = messages|merge([field.vars.label ~ ': ' ~ error.message]) %}
{% endfor %}
{% endfor %}
{% include '_partials/_alert.html.twig' with { 'level': 'error', 'title': 'Teilnehmer:in ' ~ (participant.index + 1) ~ ':' ~ participant.firstName ~ participant.lastName, 'messages': messages } %}
{% endif %}
{% endfor %}
{% endif %}
<h1 class="text-3xl font-semibold pb-2">
Buchung bearbeiten
</h1>
<div class="flex flex-col space-y-8 pb-8" {{ stimulus_controller('booking', { 'availabilities': availabilities.items }) }} {{ stimulus_controller('iframe', { 'offsetTop': 100 }) }}>
<div {{ stimulus_controller('toast') }}></div>
<h1 class="text-3xl font-semibold pb-2">Buchung bearbeiten</h1>
{# Booking metadata section #}
<div class="flex flex-col space-y-8 pb-8">
<div class="p-8 border border-gray-300 rounded-md"
{{ stimulus_controller('toggle', { 'open': true }, { 'closed': 'hidden' }) }}>
<div role="button" tabindex="0" class="flex items-center justify-between focus:outline-2 focus:outline-offset-2 focus:outline-primary-light" {{ stimulus_action('toggle', 'toggle', 'click') | stimulus_action('toggle', 'toggle', 'keydown.space') }}>
@@ -113,177 +148,278 @@
</li>
</ul>
</div>
</div>
</div>
{% for child in form.participants %}
{% set participant = child.vars.data %}
<div id="participant-{{ participant.index }}">
{% if participant.status == 'S' %}
{% do child.setRendered %}
{% endif %}
<div class="{{ html_classes('p-8 border rounded-md mb-4', { 'border-gray-300': child.vars.valid, 'border-red-700': not child.vars.valid }) }}" {{ stimulus_controller('toggle', {'open': child.vars.valid == false}, { 'closed': 'hidden' }) }}>
<div role="button" tabindex="0" class="flex items-center justify-between focus:outline-2 focus:outline-offset-2 focus:outline-primary-light" {{ stimulus_action('toggle', 'toggle', 'click') | stimulus_action('toggle', 'toggle', 'keydown.space') }}>
<div class="flex items-center space-x-4">
<span class="text-2xl leading-none font-semibold">Teilnehmer:in {{ participant.index + 1 }}: {{ participant.firstName }} {{ participant.lastName }}</span>
{% if participant.status == 'S' %} <span class="inline-block px-2 py-1 text-xs bg-red-700 text-white">storniert</span>{% endif %}
</div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6" {{ stimulus_target('toggle', 'icon') }}>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
<div class="hidden" {{ stimulus_target('toggle', 'toggle') }}>
<div class="flex flex-col space-y-4 pt-4">
{% if participant.status == 'S' %}
{% set surcharges = bookingData.surchargesForParticipant(participant.index) %}
{% if surcharges|length > 0 %}
<div>
<h3 class="text-xl font-semibold">
Zuschläge
</h3>
<ul>
{% for surcharge in surcharges %}
<li>
{{ surcharge.label }}: {{ surcharge.individualPrice[participant.index]|format_currency('EUR') }}
</li>
{% endfor %}
</ul>
</div>
{# Grid layout with 2/3 form + 1/3 summary #}
<div class="grid grid-cols-3 gap-8">
<div class="col-span-2">
<h2>Teilnehmer</h2>
{{ form_start(form) }}
{% do form.participants.setRendered %}
{# This block contains the participant form fields #}
{% block participants_form %}
<div id="participants-form" class="space-y-8 pb-8"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% 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' %}
<div class="{{ html_classes('border rounded px-4 py-2', { 'border-gray-300': participantDataValid, 'border-red-700': not participantDataValid }) }}" {{ stimulus_controller('toggle', {'storageKey': 'participant_' ~ loop.index0, 'open': participant.vars.valid == false}, { 'closed': 'hidden' }) }}>
<fieldset>
<legend class="w-full flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-bold text-xl">Teilnehmer:in {{ loop.index }}</span>
{% if isCanceled %}
<span class="inline-block px-2 py-1 text-xs bg-red-700 text-white rounded">storniert</span>
{% endif %}
{% if isEligible and not isCanceled and participantPrices is defined and participantPrices[loop.index0] is defined and participantPrices[loop.index0] > 0.0 %}
<span class="text-sm font-medium text-gray-600 bg-gray-100 px-2 py-1 rounded">
{{ participantPrices[loop.index0]|number_format(2, ',', '.') }}
</span>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="relative">
<h3 class="text-xl font-semibold">
Persönliche Daten
</h3>
{% if not child.vars.data.mutable %}
<div class="pt-4">
{% include '_partials/_alert.html.twig' with {
'level': 'info',
'messages': ['Änderungen an den persönlichen Daten sind nur über die Kundenbetreuung möglich und kostenpflichtig']
} %}
</div>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4 pb-4">
{{ form_row(child.firstName) }}
{{ form_row(child.lastName) }}
{{ form_row(child.gender) }}
{{ form_row(child.dateOfBirth) }}
{{ form_row(child.nationality) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4 pb-4">
{{ form_row(child.email) }}
{{ form_row(child.mobile) }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
{{ form_row(child.bodyDimensions.height) }}
{{ form_row(child.bodyDimensions.shoeSize) }}
{{ form_row(child.bodyDimensions.weight) }}
</div>
{% if not travelData.participantDataMutable %}
<div class="absolute top-0 left-0 inset-0 cursor-not-allowed"></div>
{% endif %}
</div>
<div>
<h3 class="text-xl font-semibold">
Unterkunft
</h3>
{% set room = bookingData.roomForParticipant(participant.index) %}
{% if room %}
{{ room.label }} ({{ room.individualPrice[participant.index]|format_currency('EUR') }})
<button type="button" tabindex="0" {{ stimulus_action('toggle', 'toggle') }}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6" {{ stimulus_target('toggle', 'icon') }}>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</button>
</legend>
<div class="hidden py-4" {{ stimulus_target('toggle', 'toggle') }}>
{% if isCanceled %}
{# Show surcharges for canceled participants #}
{% set surcharges = bookingData.surchargesForParticipant(participantData.index) %}
{% if surcharges|length > 0 %}
<div>
<h3 class="text-xl font-semibold mb-2">
Zuschläge
</h3>
<ul class="list-disc pl-4">
{% for surcharge in surcharges %}
<li>
{{ surcharge.label }}: {{ surcharge.individualPrice[participantData.index]|format_currency('EUR') }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% else %}
Keine Unterkunft zugeordnet
{% endif %}
</div>
<div class="relative">
<h3 class="text-xl font-semibold">
Leistungen
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
{% if child.courses is defined %}
{{ form_row(child.courses) }}
{# Personal data section #}
<div class="grid grid-cols-2 gap-4 pb-4">
{{ form_row(participant.firstName) }}
{{ form_row(participant.lastName) }}
{{ form_row(participant.dateOfBirth, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{{ form_row(participant.gender) }}
{{ form_row(participant.nationality) }}
</div>
<div class="grid grid-cols-2 gap-4">
{{ form_row(participant.email) }}
{{ form_row(participant.mobile) }}
</div>
{% if participant.bodyDimensions is defined %}
<div class="grid grid-cols-2 gap-4">
{{ form_row(participant.bodyDimensions.height) }}
{{ form_row(participant.bodyDimensions.shoeSize) }}
{{ form_row(participant.bodyDimensions.weight) }}
</div>
{% endif %}
{% if child.skiPass is defined %}
{{ form_row(child.skiPass) }}
{% endif %}
{% if child.additionalServices is defined %}
{{ form_row(child.additionalServices) }}
{% endif %}
{% if child.board is defined %}
{{ form_row(child.board) }}
{% endif %}
{% if child.rentals is defined %}
{{ form_row(child.rentals) }}
{% endif %}
</div>
{% if not travelData.additionalServicesMutable %}
<div class="absolute inset-0 cursor-not-allowed"></div>
{% endif %}
</div>
<div class="relative">
<h3 class="text-xl font-semibold">
Hin-/Rückreise
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4">
<div class="space-y-2" {{ stimulus_controller('select-toggle', { 'show': ['BUS'], 'visible': participant.transportationOutbound is not null and participant.transportationOutbound.subType == 'BUS' }, { 'hidden': 'hidden' }) }}>
{{ form_row(child.transportationOutbound) }}
{% if child.pickupOutbound is defined %}
<div {{ stimulus_target('select-toggle', 'container') }}>
{{ form_row(child.pickupOutbound) }}
</div>
{# Room assignment - read-only display #}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="font-semibold mb-1 block">Zimmer</label>
{% set room = bookingData.roomForParticipant(participantData.index) %}
{% if room %}
<div class="text-sm">{{ room.label }} ({{ room.individualPrice[participantData.index]|format_currency('EUR') }})</div>
{% else %}
<div class="text-sm text-gray-500">Keine Unterkunft zugeordnet</div>
{% endif %}
</div>
{% if participant.remarksRoom is defined %}
{{ form_row(participant.remarksRoom) }}
{% endif %}
</div>
{{ form_row(child.transportationInbound) }}
</div>
{% if not travelData.transportationServicesMutable %}
<div class="absolute inset-0 cursor-not-allowed"></div>
{% if not hasDateOfBirth %}
<div class="my-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-blue-800">
Leistungen sind erst nach Angabe des Geburtsdatums buchbar
</p>
</div>
</div>
{% elseif not isEligible %}
<div class="my-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
<p class="text-sm text-red-800">
Buchung wegen des Alters von Teilnehmer:in {{ loop.index }} nicht möglich
</p>
</div>
</div>
{% endif %}
{% if isEligible %}
<div class="grid grid-cols-2 gap-4">
{{ _self.service_field(participant, 'skiPass', 'Skipass', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{{ _self.service_field(participant, 'courses', 'Kurse', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{{ _self.service_field(participant, 'additionalServices', 'Zusatzleistungen', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{{ _self.service_field(participant, 'rentals', 'Leihmaterial', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}, 'Bitte zuerst den Skipass auswählen') }}
{{ _self.checkbox_field(participant, 'rentalInsurance', 'Leihmaterial-Versicherung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}, 'Nur bei Buchung von Leihmaterial') }}
{{ _self.service_field(participant, 'board', 'Verpflegung', {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
<div class="col-span-2">
<h3 class="text-xl font-semibold mb-4">Hin-/Rückreise</h3>
<div class="grid grid-cols-2 gap-4">
<div>
{% if participant.transportationOutbound is defined %}
{{ form_row(participant.transportationOutbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.pickupOutbound is defined %}
<div class="mt-4">
{{ form_row(participant.pickupOutbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
</div>
{% endif %}
{% if participant.parking is defined %}
<div class="mt-4">
{{ form_row(participant.parking, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
</div>
{% endif %}
{% if participant.licensePlate is defined %}
<div class="mt-4">
{{ form_row(participant.licensePlate, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
</div>
{% endif %}
</div>
<div>
{% if participant.transportationInbound is defined %}
{{ form_row(participant.transportationInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
{% endif %}
{% if participant.pickupInbound is defined %}
<div class="mt-4">
{{ form_row(participant.pickupInbound, {
'attr': {
'hx-trigger': 'change',
'hx-post': path('app_booking_edit_refresh', {'id': bookingData.id}),
'hx-swap': 'none'
}
}) }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-4">
<div>
<h3 class="text-xl font-semibold">
Gesamtpreis
</h3>
<span class="text-2xl font-medium">{{ bookingData.priceForParticipant(participant.index)|format_currency('EUR') }}</span>
</div>
{% set surcharges = bookingData.surchargesForParticipant(participant.index) %}
{% if surcharges|length > 0 %}
<div>
<h3 class="text-xl font-semibold">
Zuschläge
</h3>
<ul>
{% for surcharge in surcharges %}
<li>
{{ surcharge.label }}: {{ surcharge.individualPrice[participant.index]|format_currency('EUR') }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
</fieldset>
</div>
</div>
{% endfor %}
</div>
{% if participant.status != 'S' %}
<button type="submit"
class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle', null, { 'scroll': true }) }}>
Aktualisieren
</button>
{% endif %}
{% endblock %}
<div class="flex justify-between">
<a href="{{ path('app_bookings') }}" class="button bg-button bg-button--secondary">Zurück</a>
<button type="submit" class="button bg-button bg-button--secondary">Aktualisieren</button>
</div>
{% endfor %}
{{ form_rest(form) }}
{{ form_end(form) }}
</div>
{% block booking_summary %}
<div id="booking-summary"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
{% include 'booking/_summary.html.twig' with {
'bookingCreateDto': bookingEditDto,
'participantCount': participantCount,
'groupedSelectedRooms': groupedSelectedRooms,
'assignmentCounts': assignmentCounts
} %}
</div>
{% endblock %}
</div>
<div class="flex justify-between">
<a href="{{ path('app_bookings') }}"
class="button bg-button" {{ stimulus_action('loading', 'toggle') }}>
zurück
</a>
<button type="submit"
class="button bg-button bg-button--secondary" {{ stimulus_action('loading', 'toggle', null, { 'scroll': true }) }}>
Aktualisieren
</button>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% endblock %}
{% endblock %}