feat: add the booking board with floor plan and calendar
This commit is contained in:
1 parent
f4741e1bee
commit
59feca2f8e
22 files changed
+1015
-17
No files matched your search
@@ -4,15 +4,134 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Booking\AlreadyHoldingResourceException;
|
||||
use App\Booking\BoardPresenter;
|
||||
use App\Booking\BookingManager;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\ResourceAlreadyBookedException;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Form\BookingRequest;
|
||||
use App\Form\BookingType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Repository\BookingRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class BookingController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardPresenter $board,
|
||||
private readonly BookingManager $bookings,
|
||||
private readonly BookingRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/buchungen', name: 'app_booking', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('booking/index.html.twig');
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('booking/index.html.twig', [
|
||||
'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One route for both halves of the modal: GET renders it, POST handles the submit.
|
||||
*
|
||||
* On an invalid submit this just render()s the modal again -- Symfony turns that into a 422
|
||||
* and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces
|
||||
* itself with the errors showing. On success the response carries nothing but the two
|
||||
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
|
||||
* fragment and the modal simply disappears.
|
||||
*/
|
||||
#[Route('/buchungen/neu/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])]
|
||||
public function create(Request $request, Resource $resource, string $date): Response
|
||||
{
|
||||
if (!$resource->isActive()) {
|
||||
throw $this->createNotFoundException('Diese Ressource wird nicht mehr angeboten.');
|
||||
}
|
||||
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
|
||||
$form = $this->createForm(BookingType::class, new BookingRequest($selected), [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date]),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$requested = $form->getData();
|
||||
\assert($requested instanceof BookingRequest);
|
||||
\assert($requested->date instanceof \DateTimeImmutable);
|
||||
|
||||
try {
|
||||
$this->bookings->book($this->currentUser(), $resource, $requested->date);
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
// Deliberately renders from data already in hand: the losing side of a race has
|
||||
// a CLOSED entity manager at this point (see BookingManager::book), so this
|
||||
// branch must not query.
|
||||
$form->addError(new FormError($e->getMessage()));
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->addFlash('success', \sprintf('%s gebucht.', $resource->getLabel()));
|
||||
|
||||
return $this->boardResponse($requested->date);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])]
|
||||
public function release(Request $request, Booking $booking): Response
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser())) {
|
||||
throw $this->createAccessDeniedException('Fremde Buchungen können nicht freigegeben werden.');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('release-'.$booking->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
|
||||
}
|
||||
|
||||
$date = $booking->getDate();
|
||||
$label = $booking->getResource()->getLabel();
|
||||
$this->bookings->release($booking);
|
||||
|
||||
$this->addFlash('success', \sprintf('%s freigegeben.', $label));
|
||||
|
||||
// The bookings list posts this as an ordinary form and has no board fragments to swap.
|
||||
if (!$request->headers->has('HX-Request')) {
|
||||
return $this->redirectToRoute('app_booking');
|
||||
}
|
||||
|
||||
return $this->boardResponse($date);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
|
||||
* (or an outerHTML target that should vanish, as the modal does).
|
||||
*/
|
||||
private function boardResponse(\DateTimeImmutable $date): Response
|
||||
{
|
||||
return $this->htmxOobResponse(
|
||||
'dashboard/index.html.twig',
|
||||
['calendar', 'floorplan', 'flashes'],
|
||||
$this->board->context($date, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', ['tag' => $date->format('Y-m-d')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* Every route behind the firewall has a User; this narrows AbstractController::getUser()'s
|
||||
* UserInterface to it so the rest of the controller is typed.
|
||||
*/
|
||||
trait CurrentUserTrait
|
||||
{
|
||||
private function currentUser(): User
|
||||
{
|
||||
$user = $this->getUser();
|
||||
\assert($user instanceof User);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,55 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Booking\BoardPresenter;
|
||||
use App\Booking\DateParam;
|
||||
use App\Htmx\HxTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The booking board: a month calendar next to the floor plan for the selected day.
|
||||
*/
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(private readonly BoardPresenter $board)
|
||||
{
|
||||
return $this->render('dashboard/index.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$date = DateParam::parseOrToday($request->query->getString('tag'));
|
||||
|
||||
return $this->render('dashboard/index.html.twig', $this->board->context($date, $this->currentUser()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking a day swaps the calendar and the floor plan in one response.
|
||||
*
|
||||
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
|
||||
* client-side event choreography. Requests without the htmx header get a plain redirect
|
||||
* rather than a bare pair of fragments, so the URL stays shareable.
|
||||
*/
|
||||
#[Route('/tag/{date}', name: 'app_dashboard_day', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
|
||||
public function day(Request $request, string $date): Response
|
||||
{
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
|
||||
if (!$request->headers->has('HX-Request')) {
|
||||
return $this->redirectToRoute('app_dashboard', ['tag' => $selected->format('Y-m-d')]);
|
||||
}
|
||||
|
||||
return $this->htmxOobResponse(
|
||||
'dashboard/index.html.twig',
|
||||
['calendar', 'floorplan'],
|
||||
$this->board->context($selected, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', ['tag' => $selected->format('Y-m-d')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user