'\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->dataLoader->loadFormData($request, $id, $email, $password); if (null === $bookingDto) { $this->addFlash('error', 'Buchungsdaten nicht (mehr) verfügbar'); return $this->redirectToRoute('app_bookings'); } // Fetch booking data for display (surcharges, canceled status, etc.) $bookingData = $this->dataLoader->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); $form->handleRequest($request); // Handle form submission (clicking "Buchung aktualisieren") if ($form->isSubmitted() && $form->isValid()) { return $this->handleFormSubmission($request, $bookingDto, $id, $email); } // Always generate card data with validation state to show completeness $cardsData = $this->participantCardService->getAllCardsDataWithValidation($bookingDto); // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingDto); $templateData = [ 'form' => $form->createView(), 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'cardsData' => $cardsData, 'summaryData' => $summaryData, 'isDirty' => $this->fingerprintService->isDirty($bookingDto), 'isSubmitted' => $form->isSubmitted(), 'hasValidationErrors' => $form->isSubmitted() && false === $form->isValid(), ]; 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->dataLoader->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 = 'S' === ($bookingData->participantsStatus[$index] ?? null); if ($isCanceled) { $this->addFlash('warning', 'Stornierte Teilnehmer können nicht bearbeitet werden'); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } // Fetch fresh availabilities when entering participant form // This ensures up-to-date data and populates cache for subsequent HTMX refreshes $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel, cached: false); // 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, 'height_choices' => $this->getParameter('body_dimensions.height_choices'), 'weight_choices' => $this->getParameter('body_dimensions.weight_choices'), 'shoe_size_min' => $this->getParameter('body_dimensions.shoe_size_min'), 'shoe_size_max' => $this->getParameter('body_dimensions.shoe_size_max'), ]); $form->handleRequest($request); // Collect notifications from field handlers (run during PRE_SUBMIT) $notifications = $this->collectAndClearNotifications($bookingDto); if ($form->isSubmitted() && $form->isValid()) { // Save updated booking data to session $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); // Add notifications as flash messages (redirects destroy HTMX-triggered toasts) $this->addNotificationsAsFlashMessages($notifications); // Redirect back to cards return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingDto); // Fetch mutable data for form constraints $mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId); $templateData = [ 'form' => $form->createView(), 'participantIndex' => $index, 'bookingDto' => $bookingDto, 'bookingData' => $bookingData, 'mutableData' => $mutableData, 'summaryData' => $summaryData, 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'cancelRouteName' => 'app_booking_edit', 'cancelRouteParams' => ['id' => $id], ]; return $this->render('booking/edit/participant.html.twig', $templateData); } /** * 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 $this->travelDataService->enrichWithFreshAvailabilities($bookingDto->travel); // Fetch booking data and mutable data $bookingData = $this->dataLoader->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, 'height_choices' => $this->getParameter('body_dimensions.height_choices'), 'weight_choices' => $this->getParameter('body_dimensions.weight_choices'), 'shoe_size_min' => $this->getParameter('body_dimensions.shoe_size_min'), 'shoe_size_max' => $this->getParameter('body_dimensions.shoe_size_max'), ]); $form->handleRequest($request); // Collect notifications from field handlers $notifications = $this->collectAndClearNotifications($bookingDto); // Save updated booking data to session $this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($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, 'refreshRouteName' => 'app_booking_edit_participant_refresh', 'refreshRouteParams' => ['id' => $id, 'index' => $index], 'cancelRouteName' => 'app_booking_edit', 'cancelRouteParams' => ['id' => $id], ] ); // Add notifications to HX-Trigger header if present if (false === empty($notifications)) { $response->headers->set('HX-Trigger', json_encode([ 'showNotifications' => $notifications, ])); } return $response; } /** * Reloads booking data from API, discarding all session changes. * * GET: Returns modal HTML for confirmation * POST: Clears session and redirects to reload the booking */ #[Route( path: '/bookings/{id}/edit/reload', name: 'app_booking_edit_reload', requirements: ['id' => '\d+'] )] #[IsGranted('ROLE_USER')] public function reloadFromApi(int $id, Request $request): Response { if (Request::METHOD_POST === $request->getMethod()) { // Clear session to discard all changes $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); $this->addFlash('success', 'Änderungen verworfen, Daten neu geladen'); return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } return $this->render('booking/edit/modal_reload.html.twig', [ 'bookingId' => $id, ]); } /** * Handles "Zurück" button - shows confirmation modal or clears session. * * GET: Returns modal HTML for confirmation * POST: Clears session and redirects to bookings list */ #[Route( path: '/bookings/{id}/edit/cancel', name: 'app_booking_edit_cancel', requirements: ['id' => '\d+'] )] #[IsGranted('ROLE_USER')] public function cancelEdit(int $id, Request $request): Response { if (Request::METHOD_POST === $request->getMethod()) { // Clear session to discard dirty state $this->bookingService->clearBookingDto($request, BookingDto::MODE_EDIT); return $this->redirectToRoute('app_bookings'); } return $this->render('booking/edit/modal_cancel.html.twig', [ 'bookingId' => $id, ]); } /** * Handles form submission for booking update. */ private function handleFormSubmission(Request $request, BookingDto $bookingDto, int $id, string $email): Response { $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 { // Invalidate cache and clear session on success $this->dataLoader->invalidateBookingCache($id); $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->redirectToRoute('app_booking_edit', ['id' => $id]); } } catch (TimeoutException $e) { $this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.'); $this->logger->error('Booking update timeout', [ 'email' => $email, 'booking_id' => $id, 'exception' => $e->getMessage(), ]); } catch (ApiClientException) { $this->addFlash('error', 'Es ist ein Fehler in der Kommunikation mit dem Buchungssystem aufgetreten'); } return $this->redirectToRoute('app_booking_edit', ['id' => $id]); } }