'\d+'])] #[IsGranted('ROLE_USER')] public function index(int $id, Request $request): Response { /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); $password = $this->crypt->decrypt($user->getPassword()); // Load form data from session (or API on first load) $bookingDto = $this->loadFormData($request, $id, $email, $password); if (null === $bookingDto) { return $this->redirectToRoute('app_bookings'); } // Reset staleness timer when first loading the cards view (not HTMX requests) // This prevents false staleness warnings from old edit sessions if (false === $this->isHxRequest($request)) { $bookingDto->lastSessionUpdate = new \DateTimeImmutable(); $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); } // Fetch booking data for display (surcharges, canceled status, etc.) $bookingData = $this->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); return $this->redirectToRoute('app_bookings'); } // Fetch mutable data for form constraints $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); // Create validation form (same pattern as CreateStep2Controller) $form = $this->createForm(BookingEditType::class, $bookingDto, [ 'validation_groups' => ['booking_edit'], ]); $form->handleRequest($request); // Handle form submission (clicking "Buchung aktualisieren") if ($form->isSubmitted() && $form->isValid()) { // All participants validated successfully, submit to API $this->logger->info('Initiated booking update', [ 'email' => $email, 'booking_id' => $id, ]); try { $response = $this->apiClient->updateBooking($bookingDto, true); if ($response instanceof Notification) { if (true === $response->isError()) { $this->addFlash('error', $response->message); } else { $this->addFlash('info', $response->message); } $this->logger->error('Booking update not successful', [ 'email' => $email, 'booking_id' => $id, 'message' => $response->message, ]); } else { try { $cacheKey = sprintf('bpn_booking_%d', $id); $this->cache->delete($cacheKey); } catch (InvalidArgumentException $e) { } // Clear session on successful save $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); $this->addFlash('success', 'Buchung erfolgreich aktualisiert'); $this->logger->info('Booking update successful', [ 'email' => $email, 'booking_id' => $id, ]); return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } } catch (ApiClientException $e) { $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); } return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } // Generate card data for all participants $cardsData = $this->participantCardService->getAllCardsData($bookingDto); // Calculate summary data for sidebar $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingDto); // Group selected rooms for summary display $availableRooms = $bookingDto->travel->getAvailableRooms(); $groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType( $bookingDto->getSelectedRooms(), $availableRooms ); $templateData = [ 'form' => $form->createView(), 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'cardsData' => $cardsData, 'participantsCount' => count($bookingDto->participants), 'pricingData' => $summary['pricing'], 'groupedSelectedRooms' => $groupedSelectedRooms, 'assignmentCounts' => $roomAssignmentCounts, 'isDirty' => $this->fingerprintService->isDirty($bookingDto), 'hasValidationErrors' => $form->isSubmitted() && false === $form->isValid(), ]; // If HTMX request, render only blocks to avoid layout duplication if ($this->isHxRequest($request)) { return $this->htmxOobResponse( 'booking/edit/index.html.twig', ['participant_cards', 'booking_summary'], $templateData ); } // Regular request: render full template return $this->render('booking/edit/index.html.twig', $templateData); } /** * Edit single participant form. */ #[Route( path: '/bookings/{id}/edit/participants/{index}', name: 'app_booking_edit_participant', requirements: ['id' => '\d+', 'index' => '\d+'] )] #[IsGranted('ROLE_USER')] public function editParticipant(int $id, int $index, Request $request): Response { /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); $password = $this->crypt->decrypt($user->getPassword()); // Load form data from session $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); if (null === $bookingDto) { $this->addFlash('error', 'Sitzung abgelaufen. Bitte neu laden.'); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } $participant = $bookingDto->participants[$index] ?? null; if (null === $participant) { throw new \InvalidArgumentException('Invalid participant index'); } // Fetch booking data to check for canceled status $bookingData = $this->fetchBookingData($email, $password, $id); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); return $this->redirectToRoute('app_bookings'); } // Check if participant is canceled $isCanceled = ($bookingData->participantsStatus[$index] ?? null) === 'S'; if ($isCanceled) { // Redirect back to cards - canceled participants cannot be edited $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } // Create wrapper DTO for email uniqueness validation $wrapper = new ParticipantEditDto( participant: $participant, bookingContext: $bookingDto, ); // Create form for participant with booking context $form = $this->createForm(BookingParticipantType::class, $wrapper, [ 'booking_context' => $bookingDto, 'validation_groups' => ['booking_edit'], ]); $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // Save updated booking data to session $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); // Redirect back to cards return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } // Calculate summary data using trait helper $summaryData = $this->calculateSummaryData($bookingDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); // Fetch mutable data for form constraints $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); // Render form and sidebar with OOB swap using htmxOobResponse // This ensures both initial load and refresh use the same block-based rendering return $this->htmxOobResponse( 'booking/_participant_form.html.twig', ['participant_form', 'booking_summary'], [ 'form' => $form->createView(), 'participantIndex' => $index, 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'summaryData' => $summaryData, 'pricingData' => $summary['pricing'], 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'submitRouteName' => 'app_booking_edit_participant', 'submitRouteParams' => ['id' => $id, 'index' => $index], 'cancelRouteName' => 'app_booking_edit', 'cancelRouteParams' => ['id' => $id], ] ); } /** * HTMX endpoint for refreshing participant form without validation. */ #[Route( path: '/bookings/{id}/edit/participants/{index}/refresh', name: 'app_booking_edit_participant_refresh', requirements: ['id' => '\d+', 'index' => '\d+'], methods: ['POST'] )] #[IsGranted('ROLE_USER')] public function refreshParticipantForm(int $id, int $index, Request $request): Response { /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); $password = $this->crypt->decrypt($user->getPassword()); // Load form data from session $bookingDto = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); if (null === $bookingDto) { return new Response('Session expired. Please reload page.', Response::HTTP_BAD_REQUEST); } // Refresh availability data $availabilities = $this->travelDataService->getAvailabilityDataCached($bookingDto->travel->id); if (null !== $availabilities) { $this->travelDataService->patchAvailabilities($bookingDto->travel, $availabilities); } // Fetch booking data and mutable data $bookingData = $this->fetchBookingData($email, $password, $id); $mutableData = null !== $bookingData && !($bookingData instanceof Notification) ? $this->travelDataService->getMutabilityData($bookingData->dateId) : null; // Create wrapper DTO for form $wrapper = new ParticipantEditDto( participant: $bookingDto->participants[$index], bookingContext: $bookingDto, ); // Create form with validation disabled $form = $this->createForm(BookingParticipantType::class, $wrapper, [ 'booking_context' => $bookingDto, 'validation_groups' => false, ]); $form->handleRequest($request); // Save updated booking data to session $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); // Collect notifications from participant DTO $participant = $bookingDto->participants[$index] ?? null; $notifications = $participant?->notifications ?? []; // Clear notifications after collecting if (null !== $participant) { $participant->notifications = []; } // Calculate summary data using trait helper $summaryData = $this->calculateSummaryData($bookingDto); $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingDto); // Render form + sidebar using htmxOobResponse $response = $this->htmxOobResponse( 'booking/_participant_form.html.twig', ['participant_form', 'booking_summary'], [ 'form' => $form, 'participantIndex' => $index, 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'summaryData' => $summaryData, 'pricingData' => $summary['pricing'], 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'submitRouteName' => 'app_booking_edit_participant', 'submitRouteParams' => ['id' => $id, 'index' => $index], 'cancelRouteName' => 'app_booking_edit', 'cancelRouteParams' => ['id' => $id], ] ); // Add notifications to HX-Trigger header if present if ([] !== $notifications) { $response->headers->set('HX-Trigger', json_encode([ 'showNotifications' => ['notifications' => $notifications], ])); } return $response; } /** * Reloads booking data from API, discarding all session changes. */ #[Route( path: '/bookings/{id}/edit/reload', name: 'app_booking_edit_reload', requirements: ['id' => '\d+'], methods: ['POST'] )] #[IsGranted('ROLE_USER')] public function reloadFromApi(int $id, Request $request): Response { // Clear session to discard all changes $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); $this->addFlash('success', 'Änderungen verworfen, Daten neu geladen'); return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id])); } /** * Handles "Zurück" button - clears session and returns to bookings list. */ #[Route( path: '/bookings/{id}/edit/cancel', name: 'app_booking_edit_cancel', requirements: ['id' => '\d+'], methods: ['POST'] )] #[IsGranted('ROLE_USER')] public function cancelEdit(Request $request): Response { // Clear session to discard dirty state $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); return $this->hxRedirect($request, $this->generateUrl('app_bookings')); } /** * Loads form data from session or initializes from API on first load. * * @return BookingDto|null The form data, or null on error */ private function loadFormData(Request $request, int $bookingId, string $email, string $password): ?BookingDto { // Try to load from session first $formData = $this->bookingService->getBookingDto($request, BookingDto::MODE_EDIT); if (null === $formData) { // First load: initialize from API return $this->initializeFromApi($request, $bookingId, $email, $password); } // Subsequent load: refresh from session with staleness check return $this->refreshFromSession($formData); } /** * Initializes form data from API on first load and stores in session. * * @return BookingDto|null The form data, or null on error */ private function initializeFromApi(Request $request, int $bookingId, string $email, string $password): ?BookingDto { $bookingData = $this->fetchBookingData($email, $password, $bookingId); if (null === $bookingData || $bookingData instanceof Notification) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); return null; } $this->denyAccessUnlessGranted('EDIT', $bookingData); $travelData = $this->travelDataService->getTravelData($bookingData->dateId); if (null === $travelData) { $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); return null; } $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); $availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId); if (null === $mutableData || null === $availabilities) { $this->addFlash('error', 'Reisedaten nicht (mehr) verfügbar'); return null; } $this->travelDataService->patchAvailabilities($travelData, $availabilities); $this->travelDataService->patchMutability($travelData, $mutableData); $formData = $this->bookingDataProcessor->createBookingDtoFromBooking($bookingData, $travelData); // Set original fingerprint for dirty state detection $formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true); $this->bookingService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT); return $formData; } /** * Refreshes form data loaded from session with latest availability. * * @return BookingDto The refreshed form data */ private function refreshFromSession(BookingDto $formData): BookingDto { // Refresh availability data $availabilities = $this->travelDataService->getAvailabilityDataCached($formData->travel->id); if (null !== $availabilities) { $this->travelDataService->patchAvailabilities($formData->travel, $availabilities); } // Show staleness warning if session is older than 5 minutes if (null !== $formData->lastSessionUpdate) { $ageInSeconds = (new \DateTimeImmutable())->getTimestamp() - $formData->lastSessionUpdate->getTimestamp(); if ($ageInSeconds > 300) { $minutes = (int) ceil($ageInSeconds / 60); $this->addFlash('info', sprintf('Sie bearbeiten diese Buchung seit %d Minuten. Die Daten könnten veraltet sein.', $minutes)); } } return $formData; } }