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

This commit is contained in:
Björn Fromme
2026-08-03 15:25:10 +02:00
parent eabed8295a
commit 9d2aa11fdb
258 changed files with 16231 additions and 582 deletions
@@ -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,
]);
}
}