feat: groups price calculator admin crud, booking/offer flow and api

This commit is contained in:
Björn Fromme committed 2026-08-03 15:25:10 +02:00
1 parent eabed8295a
commit 9d2aa11fdb
258 files changed
+16231 -582

No files matched your search

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Accommodation;
use App\Entity\Groups\Accommodation;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CalendarController extends AbstractController
{
#[Route('/admin/accommodation/{id}/calendar', name: 'app_admin_accommodation_calendar', methods: ['GET'])]
public function index(Accommodation $accommodation, Request $request): Response
{
return $this->render('admin/accommodation/_calendar_section.html.twig', [
'accommodation' => $accommodation,
'startMonth' => $request->query->get('month'),
]);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Accommodation;
use App\Entity\Groups\Accommodation;
use App\Form\Admin\Groups\AccommodationType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation/create', name: 'app_admin_accommodation_create')]
public function index(Request $request): Response
{
$accommodation = new Accommodation();
$form = $this->createForm(AccommodationType::class, $accommodation, ['hx_post' => $request->getRequestUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($accommodation);
$this->entityManager->flush();
$this->addFlash('success', 'Das Gruppenhaus wurde angelegt');
$this->logger->info('Created accommodation', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodation'));
}
return $this->render('admin/accommodation/modal_create.html.twig', [
'accommodation' => $accommodation,
'form' => $form,
]);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Accommodation;
use App\Entity\Groups\Accommodation;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation/{id}/delete', name: 'app_admin_accommodation_delete')]
public function index(Accommodation $accommodation, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('delete_accommodation_'.$accommodation->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->entityManager->remove($accommodation);
$this->entityManager->flush();
$this->addFlash('success', 'Das Gruppenhaus wurde gelöscht');
$this->logger->info('Deleted accommodation', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodation'));
}
return $this->render('admin/accommodation/modal_delete.html.twig', [
'accommodation' => $accommodation,
'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(),
]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Accommodation;
use App\Entity\Groups\Accommodation;
use App\Form\Admin\Groups\AccommodationType;
use App\Service\CmsDataProvider;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
private readonly CmsDataProvider $cmsDataProvider,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation/{id}/edit', name: 'app_admin_accommodation_edit')]
public function index(Accommodation $accommodation, Request $request): Response
{
// fetch additional data from cms if available
$cmsData = null;
if (null !== $accommodation->getCalendarCode()) {
$cmsData = $this->cmsDataProvider->getHotelDetails($accommodation->getEffectiveCmsCode());
}
$form = $this->createForm(AccommodationType::class, $accommodation);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($accommodation);
$this->entityManager->flush();
$this->addFlash('success', 'Das Gruppenhaus wurde aktualisiert');
$this->logger->info('Updated accommodation', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
]);
return $this->redirectToRoute('app_admin_accommodation_edit', ['id' => $accommodation->getId()]);
}
return $this->render('admin/accommodation/edit.html.twig', [
'accommodation' => $accommodation,
'form' => $form,
'cmsData' => $cmsData,
]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Accommodation;
use App\Repository\Groups\AccommodationRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
private readonly AccommodationRepository $accommodationRepository,
) {
}
#[Route('/admin/accommodation', name: 'app_admin_accommodation')]
public function index(): Response
{
$accommodations = $this->accommodationRepository->findBy([], ['calendarCode' => 'ASC']);
return $this->render('admin/accommodation/index.html.twig', [
'accommodations' => $accommodations,
]);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route('/admin/accommodation-booking/create', name: 'app_admin_accommodationbooking_create')]
public function index(Request $request): Response
{
$booking = new AccommodationBooking();
$form = $this->createForm(AccommodationBookingType::class, $booking, [
'with_accommodation' => true,
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking);
$this->entityManager->flush();
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('success', 'Die Buchung wurde erstellt');
$this->logger->info('Created accommodation booking', [
'id' => $booking->getId(),
'groupName' => $booking->getGroupName(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
}
return $this->render('admin/accommodation_booking/create.html.twig', [
'form' => $form,
]);
}
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\AccommodationBookingType;
use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BoardServiceRepository $boardServiceRepo,
private readonly AdditionalServiceRepository $additionalServiceRepo,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route('/admin/accommodation-booking/{id}/edit', name: 'app_admin_accommodationbooking_edit')]
public function index(AccommodationBooking $booking, Request $request): Response
{
$accommodation = $booking->getAccommodation();
$boardServices = [];
$additionalServices = [];
$currentBoardService = null;
$currentAdditionalServices = [];
if (null !== $accommodation && null !== $booking->getDateFrom() && null !== $booking->getDateTo()) {
$boardServices = $this->boardServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$booking->getDateFrom(),
$booking->getDateTo(),
);
$additionalServices = $this->additionalServiceRepo->findByAccommodationAndDateRange(
$accommodation,
$booking->getDateFrom(),
$booking->getDateTo(),
);
// Pre-select current board service if it is still in the available choices
foreach ($boardServices as $bs) {
if ($bs->getId() === $booking->getBoardServiceOriginalId()) {
$currentBoardService = $bs;
break;
}
}
// Pre-select current additional services that are still in the available choices
$currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId');
$currentAdditionalServices = array_values(array_filter(
$additionalServices,
fn (AdditionalService $s) => in_array($s->getId(), $currentIds, true),
));
}
$form = $this->createForm(AccommodationBookingType::class, $booking, [
'max_adolescent_age' => $accommodation?->getMaxAdolescentAge() ?? 0,
'board_services' => $boardServices,
'additional_services' => $additionalServices,
'current_board_service' => $currentBoardService,
'current_additional_services' => $currentAdditionalServices,
]);
$wasInquiry = $booking->isInquiry();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($form->has('boardService')) {
$selectedBoardService = $form->get('boardService')->getData();
if ($selectedBoardService instanceof BoardService) {
$booking->setBoardServiceLabel($selectedBoardService->getLabel());
$booking->setBoardServicePrice($selectedBoardService->getPrice());
$booking->setBoardServiceOriginalId($selectedBoardService->getId());
} else {
$booking->setBoardServiceLabel(null);
$booking->setBoardServicePrice(null);
$booking->setBoardServiceOriginalId(null);
}
}
if ($form->has('selectedAdditionalServices')) {
$booking->setAdditionalServices([]);
foreach ($form->get('selectedAdditionalServices')->getData() as $service) {
$booking->addAdditionalServiceSnapshot(
$service->getLabel() ?? '',
$service->getPrice() ?? 0,
$service->getType(),
$service->getId(),
);
}
}
$this->bookingService->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking);
$this->entityManager->flush();
if ($wasInquiry && !$booking->isInquiry()) {
$this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
}
$this->addFlash('success', 'Die Buchung wurde aktualisiert');
$this->logger->info('Updated accommodation booking', [
'id' => $booking->getId(),
'groupName' => $booking->getGroupName(),
]);
return $this->redirectToRoute('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]);
}
return $this->render('admin/accommodation_booking/edit.html.twig', [
'booking' => $booking,
'form' => $form,
]);
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class GenerateAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/generate-access-link', name: 'app_admin_accommodationbooking_generate_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->bookingService->regenerateAccessLink($booking);
$this->addFlash('success', 'Der Zugangslink wurde neu generiert.');
$this->logger->info('Generated accommodation booking access link', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(),
]);
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Repository\Groups\AccommodationBookingRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class IndexController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
public function index(Request $request): Response
{
$today = new \DateTimeImmutable('today');
$qb = $this
->bookingRepository
->createQueryBuilder('booking')
->leftJoin('booking.accommodation', 'accommodation')
->addSelect('accommodation')
->where('booking.dateTo >= :today')
->setParameter('today', $today)
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
'defaultSortFieldName' => 'booking.createdAt',
'defaultSortDirection' => 'DESC',
]
);
return $this->render('admin/accommodation_booking/index.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class SendAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/send-access-link', name: 'app_admin_accommodationbooking_send_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if (null === $booking->getAccessLinkIssuedAt()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('send_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.');
$this->logger->info('Sent accommodation booking access link', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_send_access_link.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'send_accommodation_booking_access_link_'.$booking->getId(),
]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class ShowController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingLinkSigner $linkSigner,
) {
}
#[Route('/admin/accommodation-booking/{id}', name: 'app_admin_accommodationbooking_show')]
public function index(AccommodationBooking $booking, Request $request): Response
{
$hasAccessLink = null !== $booking->getAccessLinkIssuedAt();
return $this->render('admin/accommodation_booking/show.html.twig', [
'booking' => $booking,
'priceBreakdown' => $this->breakdownCalculator->compute($booking),
'returnUrl' => $this->getReturnUrl($request, 'app_admin_accommodationbooking'),
'accessLink' => $hasAccessLink ? $this->linkSigner->sign($booking) : null,
'accessLinkExpiresAt' => $hasAccessLink ? $this->linkSigner->expiresAt($booking) : null,
]);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationPrice;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationPrice;
use App\Form\Admin\Groups\AccommodationPriceType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-price/{id}/create', name: 'app_admin_accommodationprice_create')]
public function index(Accommodation $accommodation, Request $request): Response
{
$accommodationPrice = new AccommodationPrice();
$form = $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$accommodation->addAccommodationPrice($accommodationPrice);
$this->entityManager->persist($accommodationPrice);
$this->entityManager->flush();
$this->addFlash('success', 'Der Preis wurde angelegt');
$this->logger->info('Created accommodation price', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
]
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/create.html.twig', [
'accommodation' => $accommodation,
'form' => $form,
]);
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationPrice;
use App\Entity\Groups\AccommodationPrice;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')]
public function index(AccommodationPrice $accommodationPrice, Request $request): Response
{
$accommodation = $accommodationPrice->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('delete_accommodation_price_'.$accommodationPrice->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->entityManager->remove($accommodationPrice);
$this->entityManager->flush();
$this->addFlash('success', 'Der Preis wurde gelöscht');
$this->logger->info('Deleted accommodation price', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
],
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/modal_delete.html.twig', [
'accommodationPrice' => $accommodationPrice,
'accommodation' => $accommodation,
'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(),
]);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AccommodationPrice;
use App\Entity\Groups\AccommodationPrice;
use App\Form\Admin\Groups\AccommodationPriceType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-price/{id}/edit', name: 'app_admin_accommodationprice_edit', defaults: ['duplicate' => false])]
#[Route('/admin/accommodation-price/{id}/duplicate', name: 'app_admin_accommodationprice_duplicate', defaults: ['duplicate' => true])]
public function index(AccommodationPrice $accommodationPrice, bool $duplicate, Request $request): Response
{
$accommodation = $accommodationPrice->getAccommodation();
if (true === $duplicate) {
$accommodationPrice = clone $accommodationPrice;
}
$form = $this->createForm(AccommodationPriceType::class, $accommodationPrice, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (true === $duplicate) {
$accommodation->addAccommodationPrice($accommodationPrice);
}
$this->entityManager->persist($accommodationPrice);
$this->entityManager->flush();
$this->addFlash('success', 'Der Preis wurde aktualisiert');
$this->logger->info('Edited accommodation price', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
]
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#prices');
}
return $this->render('admin/accommodation_price/edit.html.twig', [
'accommodation' => $accommodation,
'accommodationPrice' => $accommodationPrice,
'form' => $form,
'duplicate' => $duplicate,
]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Form\Admin\Groups\AdditionalServiceType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/additional-service/{id}/create', name: 'app_admin_additionalservice_create')]
public function index(Accommodation $accommodation, Request $request): Response
{
$additionalService = new AdditionalService();
$form = $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$accommodation->addAdditionalService($additionalService);
$this->entityManager->persist($additionalService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zusatzleistung wurde angelegt');
$this->logger->info('Created additional service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $additionalService->getLabel(),
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/create.html.twig', [
'accommodation' => $accommodation,
'form' => $form,
]);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Entity\Groups\AdditionalService;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/additional-service/{id}/delete', name: 'app_admin_additionalservice_delete')]
public function index(AdditionalService $additionalService, Request $request): Response
{
$accommodation = $additionalService->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('delete_additional_service_'.$additionalService->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->entityManager->remove($additionalService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zusatzleistung wurde gelöscht');
$this->logger->info('Deleted additional service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $additionalService->getLabel(),
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/modal_delete.html.twig', [
'additionalService' => $additionalService,
'accommodation' => $accommodation,
'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(),
]);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\AdditionalService;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Form\Admin\Groups\AdditionalServiceType;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/additional-service/{id}/edit', name: 'app_admin_additionalservice_edit', defaults: ['duplicate' => false])]
#[Route('/admin/additional-service/{id}/duplicate', name: 'app_admin_additionalservice_duplicate', defaults: ['duplicate' => true])]
public function index(AdditionalService $additionalService, bool $duplicate, Request $request): Response
{
$accommodation = $additionalService->getAccommodation();
if (true === $duplicate) {
$additionalService = clone $additionalService;
}
$form = $this->createForm(AdditionalServiceType::class, $additionalService, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (true === $duplicate) {
$accommodation->addAdditionalService($additionalService);
}
$this->entityManager->persist($additionalService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Zusatzleistung wurde aktualisiert');
$this->logger->info('Edited additional service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $additionalService->getLabel(),
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#additional-services');
}
return $this->render('admin/additional_service/edit.html.twig', [
'accommodation' => $accommodation,
'additionalService' => $additionalService,
'form' => $form,
'duplicate' => $duplicate,
]);
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\BoardServiceType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class CreateController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/board-service/{id}/create', name: 'app_admin_boardservice_create')]
public function index(Accommodation $accommodation, Request $request): Response
{
$boardService = new BoardService();
$form = $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$accommodation->addBoardService($boardService);
$this->entityManager->persist($boardService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Verpflegungsleistung wurde angelegt');
$this->logger->info('Created board service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $boardService->getLabel(),
]);
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#board-services');
}
return $this->render('admin/board_service/create.html.twig', [
'accommodation' => $accommodation,
'form' => $form,
]);
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Entity\Groups\BoardService;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/board-service/{id}/delete', name: 'app_admin_boardservice_delete')]
public function index(BoardService $boardService, Request $request): Response
{
$accommodation = $boardService->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('delete_board_service_'.$boardService->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->entityManager->remove($boardService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Verpflegungsleistung wurde gelöscht');
$this->logger->info('Deleted board service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $boardService->getLabel(),
]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return new HxRedirectResponse($returnUrl.'#board-services');
}
return $this->render('admin/board_service/modal_delete.html.twig', [
'boardService' => $boardService,
'accommodation' => $accommodation,
'csrf_token_id' => 'delete_board_service_'.$boardService->getId(),
]);
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BoardService;
use App\Entity\Groups\BoardService;
use App\Form\Admin\Groups\BoardServiceType;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/board-service/{id}/edit', name: 'app_admin_boardservice_edit', defaults: ['duplicate' => false])]
#[Route('/admin/board-service/{id}/duplicate', name: 'app_admin_boardservice_duplicate', defaults: ['duplicate' => true])]
public function index(BoardService $boardService, bool $duplicate, Request $request): Response
{
$accommodation = $boardService->getAccommodation();
if (true === $duplicate) {
$boardService = clone $boardService;
}
$form = $this->createForm(BoardServiceType::class, $boardService, ['currency' => $accommodation->getCurrency()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if (true === $duplicate) {
$accommodation->addBoardService($boardService);
}
$this->entityManager->persist($boardService);
$this->entityManager->flush();
$this->addFlash('success', 'Die Verpflegungsleistung wurde aktualisiert');
$this->logger->info('Edited board service', [
'name' => $accommodation->getName(),
'code' => $accommodation->getCalendarCode(),
'label' => $boardService->getLabel(),
]);
$returnUrl = $this->redirectToRoute('app_admin_accommodation_edit', [
'id' => $accommodation->getId(),
]);
return $this->redirect($returnUrl.'#board-services');
}
return $this->render('admin/board_service/edit.html.twig', [
'accommodation' => $accommodation,
'boardService' => $boardService,
'form' => $form,
'duplicate' => $duplicate,
]);
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\BookingEditDraft;
use App\Htmx\HxRedirectResponse;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class DeleteController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $adminLogger,
) {
}
#[Route('/admin/booking-edit-draft/{id}/delete', name: 'app_admin_bookingeditdraft_delete')]
public function index(BookingEditDraft $draft, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('delete_booking_edit_draft_'.$draft->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->entityManager->remove($draft);
$this->entityManager->flush();
$this->adminLogger->info('Delete booking edit draft', [
'booking_number' => $draft->getBookingNumber(),
]);
$this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht');
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
return new HxRedirectResponse($returnUrl);
}
return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [
'draft' => $draft,
'csrf_token_id' => 'delete_booking_edit_draft_'.$draft->getId(),
]);
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\BookingEditDraft;
use App\Service\BookingExporter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class ExportController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(private readonly BookingExporter $bookingExporter)
{
}
#[Route('/admin/booking-edit-draft/{id}/export', name: 'app_admin_bookingeditdraft_export')]
public function index(BookingEditDraft $draft): Response
{
if (false === $draft->hasExportData()) {
$this->addFlash('warning', 'Der Export ist fehlgeschlagen');
return $this->redirectToRoute('app_admin_bookingeditdraft');
}
try {
return $this->bookingExporter->createExportResponse($draft);
} catch (\RuntimeException $e) {
$this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage());
return $this->redirectToRoute('app_admin_bookingeditdraft');
}
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ReturnUrlTrait;
use App\Repository\BookingEditDraftRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
public function index(Request $request): Response
{
$qb = $this
->bookingEditDraftRepository
->createQueryBuilder('booking_edit_draft')
->leftJoin('booking_edit_draft.user', 'user')
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
'defaultSortFieldName' => 'booking_edit_draft.createdAt',
'defaultSortDirection' => 'DESC',
]
);
return $this->render('admin/booking_edit_draft/index.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\BookingEditDraft;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class ShowController extends AbstractController
{
use ReturnUrlTrait;
#[Route('/admin/booking-edit-draft/{id}/show', name: 'app_admin_bookingeditdraft_show')]
public function index(BookingEditDraft $draft, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
return $this->render('admin/booking_edit_draft/show.html.twig', [
'draft' => $draft,
'returnUrl' => $returnUrl,
]);
}
}
@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\BookingEditDraft;
use App\Htmx\HxRedirectResponse;
use App\Repository\BookingEditDraftRepository;
use App\Service\BookingExporter;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class BookingEditDraftController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
private readonly PaginatorInterface $paginator,
private readonly BookingExporter $bookingExporter,
private readonly LoggerInterface $adminLogger,
) {
}
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
public function index(Request $request): Response
{
$qb = $this
->bookingEditDraftRepository
->createQueryBuilder('booking_edit_draft')
->leftJoin('booking_edit_draft.user', 'user')
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
'defaultSortFieldName' => 'booking_edit_draft.createdAt',
'defaultSortDirection' => 'DESC',
]
);
return $this->render('admin/booking_edit_draft/index.html.twig', [
'pagination' => $pagination,
]);
}
#[Route('/admin/booking-edit-draft/{id}/show', name: 'app_admin_bookingeditdraft_show')]
public function show(BookingEditDraft $draft, Request $request): Response
{
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
return $this->render('admin/booking_edit_draft/show.html.twig', [
'draft' => $draft,
'returnUrl' => $returnUrl,
]);
}
#[Route('/admin/booking-edit-draft/{id}/delete', name: 'app_admin_bookingeditdraft_delete')]
public function delete(BookingEditDraft $draft, Request $request): Response
{
if (true === $request->isMethod(Request::METHOD_POST)) {
$this->entityManager->remove($draft);
$this->entityManager->flush();
$this->adminLogger->info('Delete booking edit draft', [
'booking_number' => $draft->getBookingNumber(),
]);
$this->addFlash('success', 'Der Buchungsentwurf wurde gelöscht');
$returnUrl = $this->getReturnUrl($request, 'app_admin_bookingeditdraft');
return new HxRedirectResponse($returnUrl);
}
return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [
'draft' => $draft,
]);
}
#[Route('/admin/booking-edit-draft/{id}/export', name: 'app_admin_bookingeditdraft_export')]
public function export(BookingEditDraft $draft): Response
{
if (false === $draft->hasExportData()) {
$this->addFlash('warning', 'Der Export ist fehlgeschlagen');
return $this->redirectToRoute('app_admin_bookingeditdraft');
}
try {
return $this->bookingExporter->createExportResponse($draft);
} catch (\RuntimeException $e) {
$this->addFlash('warning', 'Der Export ist fehlgeschlagen: '.$e->getMessage());
return $this->redirectToRoute('app_admin_bookingeditdraft');
}
}
}
@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Controller\Admin;
use App\Security\Voter\AdministrativeAccessVoter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(AdministrativeAccessVoter::ADMINISTRATIVE_ACCESS)]
class DashboardController extends AbstractController
{
#[Route('/admin/dashboard', name: 'app_admin_dashboard')]
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Service\XmlDumpReader;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class DownloadController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
{
}
#[Route('/admin/log/download/{filename}', name: 'app_admin_log_xmldump_download', requirements: ['filename' => '.+'])]
public function index(string $filename): Response
{
try {
if (false === $this->xmlDumpReader->fileExists($filename)) {
throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.');
}
$content = $this->xmlDumpReader->getContent($filename);
} catch (FilesystemException $e) {
throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage());
}
$response = new Response($content);
$response->headers->set('Content-Type', 'application/xml');
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($filename)
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Repository\LogEntryRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
private readonly LogEntryRepository $logEntryRepository,
private readonly PaginatorInterface $paginator,
) {
}
#[Route('/admin/log', name: 'app_admin_log')]
public function index(Request $request): Response
{
$qb = $this
->logEntryRepository
->createQueryBuilder('log_entry')
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
'defaultSortFieldName' => 'log_entry.createdAt',
'defaultSortDirection' => 'DESC',
]
);
return $this->render('admin/log/index.html.twig', [
'pagination' => $pagination,
]);
}
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\Log;
use App\Entity\LogEntry;
use App\Service\XmlDumpReader;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class XmlDumpController extends AbstractController
{
public function __construct(private readonly XmlDumpReader $xmlDumpReader)
{
}
#[Route('/admin/log/{id}/xml-dumps', name: 'app_admin_log_xmldumps')]
public function index(LogEntry $logEntry): Response
{
$dumps = [];
try {
$dumps = $this->xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId());
} catch (FilesystemException) {
}
return $this->render('admin/log/xml_dumps.html.twig', [
'logEntry' => $logEntry,
'dumps' => $dumps,
]);
}
}
-90
View File
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\Log;
use App\Entity\LogEntry;
use App\Repository\LogEntryRepository;
use App\Service\XmlDumpReader;
use Knp\Component\Pager\PaginatorInterface;
use League\Flysystem\FilesystemException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
class LogController extends AbstractController
{
public function __construct(
private readonly LogEntryRepository $logEntryRepository,
private readonly PaginatorInterface $paginator,
private readonly XmlDumpReader $xmlDumpReader,
) {
}
#[Route('/admin/log', name: 'app_admin_log')]
public function index(Request $request): Response
{
$qb = $this
->logEntryRepository
->createQueryBuilder('log_entry')
;
$pagination = $this->paginator->paginate(
$qb,
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
'defaultSortFieldName' => 'log_entry.createdAt',
'defaultSortDirection' => 'DESC',
]
);
return $this->render('admin/log/index.html.twig', [
'pagination' => $pagination,
]);
}
#[Route('/admin/log/{id}/xml-dumps', name: 'app_admin_log_xmldumps')]
public function xmlDumps(LogEntry $logEntry): Response
{
$dumps = [];
try {
$dumps = $this->xmlDumpReader->findDumpsForRequestId($logEntry->getRequestId());
} catch (FilesystemException) {
}
return $this->render('admin/log/xml_dumps.html.twig', [
'logEntry' => $logEntry,
'dumps' => $dumps,
]);
}
#[Route('/admin/log/download/{filename}', name: 'app_admin_log_xmldump_download', requirements: ['filename' => '.+'])]
public function downloadXmlDump(string $filename): Response
{
try {
if (false === $this->xmlDumpReader->fileExists($filename)) {
throw $this->createNotFoundException('Dump file not found. It may have been cleaned up.');
}
$content = $this->xmlDumpReader->getContent($filename);
} catch (FilesystemException $e) {
throw $this->createNotFoundException('Failed to read dump file: '.$e->getMessage());
}
$response = new Response($content);
$response->headers->set('Content-Type', 'application/xml');
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($filename)
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}
@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\Controller\Admin;
namespace App\Controller\Admin\User;
use App\Repository\UserRepository;
use Knp\Component\Pager\PaginatorInterface;
@@ -10,8 +10,10 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class UserController extends AbstractController
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
public function __construct(
private readonly UserRepository $userRepository,
@@ -0,0 +1,62 @@
<?php
namespace App\Controller\Api;
use App\Entity\Groups\AccommodationBooking;
use App\Model\AccommodationBookingApiResponse;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\SerializerInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class AccommodationBookingController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly SerializerInterface $serializer,
private readonly AccommodationBookingService $bookingService,
) {
}
#[Route(path: '/accommodation-bookings/{uuid}', name: 'api_accommodation_bookings_single', methods: ['GET'])]
public function single(string $uuid): JsonResponse
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
return $this->bookingResponse($booking);
}
#[Route(path: '/accommodation-bookings/{uuid}/accept', name: 'api_accommodation_bookings_accept', methods: ['POST'])]
public function accept(string $uuid): JsonResponse
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking) {
return new JsonResponse(['message' => 'Not found'], Response::HTTP_NOT_FOUND);
}
$this->bookingService->acceptBooking($booking);
return $this->bookingResponse($booking);
}
private function bookingResponse(AccommodationBooking $booking): JsonResponse
{
$response = new AccommodationBookingApiResponse($booking, $this->breakdownCalculator->compute($booking));
$json = $this->serializer->serialize($response, 'json', ['groups' => ['api:single']]);
return new JsonResponse($json, Response::HTTP_OK, [], true);
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\PriceType;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentPricesQuery;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Service\PriceTimelineBuilder;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class ContingentController extends AbstractController
{
public function __construct(
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationRepository $accommodationRepository,
private readonly AccommodationPriceRepository $priceRepository,
private readonly CacheInterface $cache,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
) {
}
#[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])]
public function prices(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ContingentPricesQuery $query,
): JsonResponse {
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
if (null === $accommodation) {
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
}
$yearStart = new \DateTimeImmutable("{$query->year}-01-01");
$yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd);
$currency = $accommodation->getCurrency();
return $this->json($this->priceTimelineBuilder->buildTimeline($prices, $yearStart, $yearEnd, $currency));
}
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
public function calendar(
#[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
ContingentCalendarQuery $query,
): JsonResponse {
$dateFrom = $query->dateFromDate();
$dateTo = $query->dateToDate();
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
if (null === $accommodation) {
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
}
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo);
$calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo);
});
} catch (BpnConnectException|InvalidArgumentException $e) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY);
}
$prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo);
$currency = $accommodation->getCurrency();
$data = array_map(
fn ($entry) => $this->enrichEntry($entry->date, $entry->status->value, $prices, $currency),
$calendar->data,
);
return $this->json($data);
}
/**
* @param AccommodationPrice[] $prices
*
* @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null}
*/
private function enrichEntry(string $date, string $status, array $prices, string $currency): array
{
$day = (new \DateTimeImmutable($date))->setTime(0, 0);
// dateFrom and dateTo are both inclusive (last night, not checkout day)
$candidates = array_filter(
$prices,
fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
);
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
$defaultPrice = null;
if (PriceType::DISCOUNT === $winner?->getType()) {
$defaults = array_filter($candidates, fn ($p) => null === $p->getType());
$defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults);
}
return [
'date' => $date,
'status' => $status,
'type' => $winner?->getType()?->value,
'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null,
'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null,
'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null,
'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
'currency' => $currency,
'includedPax' => $winner?->getIncludedPax(),
'minNights' => $winner?->getMinNights(),
];
}
}
@@ -49,7 +49,7 @@ class IndexController extends AbstractController
path: '/bookings/create',
name: 'app_booking_create',
)]
public function index(Request $request, #[MapQueryString] ?BookingQueryParams $params): Response
public function index(#[MapQueryString] ?BookingQueryParams $params): Response
{
if (null === $params) {
throw $this->createNotFoundException('Invalid booking parameters provided');
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\Model\AccommodationBookingDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractAccommodationController extends AbstractController
{
protected function validateStepAccess(AccommodationBookingDto $dto, int $expectedStep): ?RedirectResponse
{
if ($expectedStep > $dto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToCurrentStep($dto);
}
return null;
}
protected function createFailureResponse(\Throwable $exception, bool $htmx): Response
{
if ($htmx) {
return new Response('', 400);
}
$message = $exception instanceof AccommodationSessionNotFoundException
? 'Deine Sitzung ist abgelaufen. Bitte starte die Anfrage erneut.'
: 'Die Anfrage konnte nicht geladen werden.';
$this->addFlash('error', $message);
return $this->redirectToRoute('app_groups_booking_error');
}
private function redirectToCurrentStep(AccommodationBookingDto $dto): RedirectResponse
{
$route = match ($dto->currentStep) {
2 => 'app_groups_booking_step_2',
3 => 'app_groups_booking_step_3',
4 => 'app_groups_booking_step_4',
default => 'app_groups_booking_step_1',
};
return $this->redirectToRoute($route);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingQueryParams;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route;
class IndexController extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
/**
* Validates query params, initializes the session DTO, and redirects to step 1.
*
* No intermediate loading page is needed because all work here is DB-only and fast.
*/
#[Route('/groups/booking/init', name: 'app_groups_booking_init')]
public function init(Request $request, #[MapQueryString] ?AccommodationBookingQueryParams $params): Response
{
if (null === $params) {
$this->addFlash('error', 'Ungültige oder fehlende Parameter. Bitte überprüfe den Link.');
return $this->redirectToRoute('app_groups_booking_error');
}
try {
$this->sessionManager->clear($request);
$dto = $this->bookingService->initFromParams($params);
$this->sessionManager->save($request, $dto);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_error');
}
return $this->redirectToRoute('app_groups_booking_step_1');
}
#[Route('/groups/booking/success', name: 'app_groups_booking_success')]
public function success(Request $request): Response
{
$session = $request->getSession();
$resultType = $session instanceof FlashBagAwareSessionInterface
? $session->getFlashBag()->get('groups_booking_result')[0] ?? null
: null;
// Flash is consumed after the first read — reload/direct access ends up here with none.
if (null === $resultType) {
return $this->redirectToRoute('app_login');
}
return $this->render('groups/booking/success.html.twig', [
'resultType' => $resultType,
]);
}
#[Route('/groups/booking/error', name: 'app_groups_booking_error')]
public function error(): Response
{
return $this->render('groups/booking/error.html.twig');
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\AccommodationBooking;
use App\Form\OfferAcceptConfirmationType;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OfferController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
private readonly AccommodationBookingLinkSigner $linkSigner,
private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator,
private readonly AccommodationBookingService $bookingService,
) {
}
/**
* Signed-link entry point: validates the `t`/`_hash` query params once,
* authorizes the session for this booking, and redirects to the plain
* (session-gated) offer page — nothing downstream needs the signature again.
*/
#[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET'])]
public function access(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isValidLinkRequest($request, $booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
$this->linkSigner->authorizeSession($request, $booking);
return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]));
}
#[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_booking_offer_view', methods: ['GET'])]
public function view(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->render('groups/booking/offer_unavailable.html.twig');
}
$accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.');
$priceBreakdown = $this->breakdownCalculator->compute($booking);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/booking/offer.html.twig', [
'booking' => $booking,
'priceBreakdown' => $priceBreakdown,
'ctx' => $ctx,
]);
}
#[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_booking_offer_confirm', methods: ['GET', 'POST'])]
public function confirm(string $uuid, Request $request): Response
{
$booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]);
if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) {
return $this->redirectToOfferPage($request, $uuid);
}
if (!$booking->isInquiry()) {
return $this->redirectToOfferPage($request, $uuid);
}
$confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [
'terms_url' => $this->getParameter('terms_and_conditions_url'),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
$this->bookingService->acceptBooking($booking);
$this->addFlash('success', 'Deine Buchung ist bestätigt.');
return $this->redirectToOfferPage($request, $uuid);
}
return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [
'booking' => $booking,
'confirmationForm' => $confirmationForm,
]);
}
/**
* Sends the browser to a full reload of the (non-modal) offer page — this is
* triggered from an htmx-loaded modal, so a plain render/redirect here would
* get appended as an inert HTML fragment instead of actually navigating.
*/
private function redirectToOfferPage(Request $request, string $uuid): Response
{
$url = $this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]);
return $this->htmxRedirect($request, $url);
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\BpnConnect\ContingentsClient;
use App\BpnConnect\Exception\BpnConnectException;
use App\BpnConnect\Model\ContingentStatus;
use App\Entity\Groups\AccommodationPrice;
use App\Exception\AccommodationSessionNotFoundException;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\CalendarGridBuilder;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class Step1Controller extends AbstractAccommodationController
{
use HxTrait;
private const int CALENDAR_MONTHS = 18;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly ContingentsClient $contingentsClient,
private readonly AccommodationPriceRepository $priceRepository,
private readonly PriceTimelineBuilder $priceTimelineBuilder,
private readonly CacheInterface $cache,
private readonly CalendarGridBuilder $calendarGridBuilder,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-1', name: 'app_groups_booking_step_1', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($request->isMethod('POST')) {
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException $e) {
$this->addFlash('error', $e->getMessage());
return $this->redirectToRoute('app_groups_booking_step_1');
}
$dateFrom = $dto->dateFrom;
$dateTo = $dto->dateTo;
if (null !== $dateFrom && null !== $dateTo) {
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$dto->paxCount = $this->bookingService->computeInitialPaxCount($accommodation, $dateFrom, $dateTo);
}
// Reset service selections — no longer valid for the new date range
$dto->selectedBoardServiceId = null;
$dto->selectedAdditionalServiceIds = [];
$dto->currentStep = 2;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_2');
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->render('groups/booking/step_1.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'hotelCode' => $accommodation->getCalendarCode() ?? '',
]);
}
#[Route('/groups/booking/calendar-refresh', name: 'app_groups_booking_calendar_refresh', methods: ['POST'])]
public function calendarRefresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$dateFromRaw = $request->request->getString('date_from');
$dateToRaw = $request->request->getString('date_to');
if ('' !== $dateFromRaw && '' !== $dateToRaw) {
try {
$this->bookingService->applyDates($dto, $dateFromRaw, $dateToRaw);
} catch (\InvalidArgumentException) {
// Invalid dates — fall through with original dto state
}
} else {
$dto->dateFrom = null;
$dto->dateTo = null;
}
$this->sessionManager->save($request, $dto);
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
return new Response('', Response::HTTP_NOT_FOUND);
}
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
null,
[],
$accommodation->getCurrency() ?? 'EUR',
);
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
priceBreakdown: $priceBreakdown,
);
return $this->htmxOobResponse(
'groups/booking/_summary.html.twig',
['booking_summary'],
['dto' => $dto, 'ctx' => $ctx],
);
}
#[Route('/groups/booking/calendar-grid', name: 'app_groups_booking_calendar_grid', methods: ['GET'])]
public function calendarGrid(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return new Response('', Response::HTTP_BAD_REQUEST);
}
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
$hotelCode = $accommodation?->getCalendarCode() ?? '';
$now = new \DateTimeImmutable('today');
$calendarStart = $now->modify('first day of this month');
$calendarEnd = $calendarStart->modify('+'.(self::CALENDAR_MONTHS - 1).' months')->modify('last day of this month');
$todayStr = $now->format('Y-m-d');
$maxOffset = self::CALENDAR_MONTHS - 2;
if ($request->query->has('offset')) {
$offset = max(0, min($request->query->getInt('offset'), $maxOffset));
} else {
$offset = 0;
if ($dto->dateFrom !== null) {
$monthsDiff = ((int) $dto->dateFrom->format('Y') - (int) $calendarStart->format('Y')) * 12
+ ((int) $dto->dateFrom->format('n') - (int) $calendarStart->format('n'));
$offset = max(0, min($monthsDiff, $maxOffset));
}
}
$displayFrom = $calendarStart->modify("+{$offset} months");
$months = $this->calendarGridBuilder->buildMonths($displayFrom, 2);
$enrichedByDate = $this->buildEnrichedDayData(
$hotelCode,
$calendarStart,
$calendarEnd,
$calendarStart->format('Y-m-d'),
$calendarEnd->format('Y-m-d'),
);
return $this->render('groups/booking/_price_calendar_grid.html.twig', [
'months' => $months,
'enrichedByDate' => $enrichedByDate,
'todayStr' => $todayStr,
'offset' => $offset,
'totalMonths' => self::CALENDAR_MONTHS,
]);
}
/**
* Fetches contingent + price data and returns a map of date → ['status', 'minNights'].
*
* Returns an empty array on API failure; the template treats missing dates as blocked.
*
* @return array<string, array{status: string, minNights: int}>
*/
private function buildEnrichedDayData(
string $hotelCode,
\DateTimeImmutable $dateFrom,
\DateTimeImmutable $dateTo,
string $dateFromStr,
string $dateToStr,
): array {
try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromStr, $dateToStr);
$calendar = $this->cache->get(
$cacheKey,
function (ItemInterface $item) use ($hotelCode, $dateFromStr, $dateToStr): mixed {
$item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromStr, $dateToStr);
},
);
} catch (BpnConnectException|\Psr\Cache\InvalidArgumentException) {
return [];
}
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$availableDates = [];
foreach ($calendar->data as $entry) {
if (ContingentStatus::Ok === $entry->status) {
$availableDates[$entry->date] = true;
}
}
$enriched = [];
foreach ($calendar->data as $entry) {
$isAvailable = ContingentStatus::Ok === $entry->status;
$prevDate = (new \DateTimeImmutable($entry->date))->modify('-1 day')->format('Y-m-d');
$prevAvailable = isset($availableDates[$prevDate]);
$status = match (true) {
$isAvailable && $prevAvailable => 'ok',
$isAvailable => 'blocked-to-ok',
$prevAvailable => 'checkout-only',
default => 'blocked',
};
$day = (new \DateTimeImmutable($entry->date))->setTime(0, 0);
$candidates = array_values(array_filter(
$prices,
fn(AccommodationPrice $p): bool => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates);
$enriched[$entry->date] = [
'status' => $status,
'minNights' => $winner?->getMinNights() ?? 0,
];
}
return $enriched;
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep2Type;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use App\Service\GroupsPriceCalculator;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step2Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
private readonly GroupsPriceCalculator $priceCalculator,
) {
}
#[Route('/groups/booking/step-2', name: 'app_groups_booking_step_2')]
public function index(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($redirect = $this->validateStepAccess($dto, 2)) {
return $redirect;
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, true);
if ($form->isSubmitted() && $form->isValid()) {
$dto->currentStep = 3;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_3');
}
return $this->render('groups/booking/step_2.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
#[Route('/groups/booking/step-2/refresh', name: 'app_groups_booking_step_2_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, true);
}
[$ctx, $form] = $this->buildStep2Context($request, $dto, false);
return $this->htmxOobResponse(
'groups/booking/step_2.html.twig',
['accommodation_form', 'accommodation_summary'],
['dto' => $dto, 'ctx' => $ctx, 'form' => $form->createView()],
);
}
/**
* @return array{AccommodationBookingContext, FormInterface<AccommodationBookingDto>}
*/
private function buildStep2Context(Request $request, AccommodationBookingDto $dto, bool $validate): array
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$form = $this->createForm(AccommodationStep2Type::class, $dto, [
'board_service_choices' => $this->buildServiceChoices($services['boardServices']),
'additional_service_choices' => $this->buildServiceChoices($services['additionalServices']),
'max_adolescent_age' => (int) $accommodation->getMaxAdolescentAge(),
'validation_groups' => $validate ? ['step_2'] : false,
]);
$form->handleRequest($request);
$status = $this->bookingService->computeInquiryStatus($dto, $prices);
$dto->isInquiry = $status->isInquiry;
$dto->inquiryReasons = $status->reasons;
$boardService = $this->resolveSelectedBoardService($dto, $services['boardServices']);
$selectedAdditionalServices = $this->resolveSelectedAdditionalServices($dto, $services['additionalServices']);
$priceBreakdown = null;
if ($dto->dateFrom !== null && $dto->dateTo !== null) {
$priceBreakdown = $this->priceCalculator->calculate(
$dto->paxCount,
$dto->minorsCount,
$dto->getNights(),
$dto->dateFrom,
$dto->dateTo,
$prices,
$boardService,
$selectedAdditionalServices,
$accommodation->getCurrency() ?? 'EUR',
);
$dto->totalPrice = $priceBreakdown['total'];
$dto->priceBreakdown = $priceBreakdown;
}
$this->sessionManager->save($request, $dto);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
groupedAdditionalServices: $services['groupedAdditionalServices'],
ungroupedAdditionalServices: $services['ungroupedAdditionalServices'],
priceBreakdown: $priceBreakdown,
);
return [$ctx, $form];
}
/**
* @param BoardService[] $boardServices
*/
private function resolveSelectedBoardService(AccommodationBookingDto $dto, array $boardServices): ?BoardService
{
if (null === $dto->selectedBoardServiceId) {
return null;
}
foreach ($boardServices as $service) {
if ($service->getId() === $dto->selectedBoardServiceId) {
return $service;
}
}
return null;
}
/**
* @param AdditionalService[] $additionalServices
*
* @return AdditionalService[]
*/
private function resolveSelectedAdditionalServices(AccommodationBookingDto $dto, array $additionalServices): array
{
if (empty($dto->selectedAdditionalServiceIds)) {
return [];
}
$selectedIds = array_flip($dto->selectedAdditionalServiceIds);
return array_values(array_filter(
$additionalServices,
fn(AdditionalService $s) => isset($selectedIds[$s->getId()]),
));
}
/**
* Builds an ID-keyed choices array for Symfony form binding.
* Labels and price formatting are handled in Twig.
*
* @param BoardService[]|AdditionalService[] $services
*
* @return array<string, int>
*/
private function buildServiceChoices(array $services): array
{
$choices = [];
foreach ($services as $service) {
$choices[(string) $service->getId()] = $service->getId();
}
return $choices;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationStep3Type;
use App\Form\Model\AccommodationBookingDto;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step3Controller extends AbstractAccommodationController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')]
public function index(Request $request): Response
{
$dto = $this->resolveDto($request);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
priceBreakdown: $dto->priceBreakdown ?: null,
);
$form = $this->createForm(AccommodationStep3Type::class, $dto);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dto->currentStep = 4;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_groups_booking_step_4');
}
return $this->render('groups/booking/step_3.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
'form' => $form,
]);
}
/**
* Loads the session DTO and checks step access, returning an early
* response (session failure or step-access redirect) if either fails.
*/
private function resolveDto(Request $request): AccommodationBookingDto|Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, false);
}
if ($redirect = $this->validateStepAccess($dto, 3)) {
return $redirect;
}
return $dto;
}
private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
return $accommodation;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AdditionalService;
use App\Entity\Groups\BoardService;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step4Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-4', name: 'app_groups_booking_step_4', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
$dto = $this->resolveDto($request, false);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
if ($request->isMethod(Request::METHOD_POST)) {
$inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class);
$inquiryForm->handleRequest($request);
if (!$inquiryForm->isSubmitted() || !$inquiryForm->isValid()) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$dto->forceInquiry = true;
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->redirectToRoute('app_groups_booking_success');
}
$ctx = new AccommodationBookingContext(
accommodation: $accommodation,
hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation),
boardServices: $services['boardServices'],
additionalServices: $services['additionalServices'],
priceBreakdown: $dto->priceBreakdown ?: null,
);
return $this->render('groups/booking/step_4.html.twig', [
'dto' => $dto,
'ctx' => $ctx,
]);
}
#[Route('/groups/booking/step-4/confirm-inquiry', name: 'app_groups_booking_step_4_confirm_inquiry', methods: ['GET'])]
public function confirmInquiry(Request $request): Response
{
$dto = $this->resolveDto($request, true);
if ($dto instanceof Response) {
return $dto;
}
$inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class);
return $this->render('groups/booking/_step4_inquiry_confirmation_modal.html.twig', [
'inquiryForm' => $inquiryForm,
]);
}
#[Route('/groups/booking/step-4/confirm', name: 'app_groups_booking_step_4_confirm', methods: ['GET', 'POST'])]
public function confirm(Request $request): Response
{
$dto = $this->resolveDto($request, true);
if ($dto instanceof Response) {
return $dto;
}
$accommodation = $this->loadAccommodationOrFail($dto);
$confirmationForm = $this->createForm(AccommodationBookingConfirmationType::class, $dto, [
'terms_url' => $this->getParameter('terms_and_conditions_url'),
]);
$confirmationForm->handleRequest($request);
if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) {
$services = $this->bookingService->loadAvailableServices($dto, $accommodation);
$this->persistBooking($request, $dto, $accommodation, $services);
return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success'));
}
return $this->render('groups/booking/_step4_booking_confirmation_modal.html.twig', [
'confirmationForm' => $confirmationForm,
]);
}
/**
* Loads the session DTO and checks step access, returning an early
* response (session failure or step-access redirect) if either fails.
*/
private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response
{
try {
$dto = $this->sessionManager->getOrFail($request);
} catch (AccommodationSessionNotFoundException $e) {
return $this->createFailureResponse($e, $htmx);
}
if ($redirect = $this->validateStepAccess($dto, 4)) {
return $redirect;
}
return $dto;
}
private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation
{
$accommodation = $this->bookingService->loadAccommodation($dto->accommodationId);
if (null === $accommodation) {
throw $this->createNotFoundException();
}
return $accommodation;
}
/**
* @param array{
* boardServices: BoardService[],
* additionalServices: AdditionalService[],
* groupedAdditionalServices: array<string, AdditionalService[]>,
* ungroupedAdditionalServices: AdditionalService[]
* } $services
*/
private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void
{
$prices = $this->bookingService->loadPrices($dto, $accommodation);
$booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services);
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
$this->sessionManager->clear($request);
}
}