feat: add the booking board with floor plan and calendar

This commit is contained in:
fromme committed 2026-09-13 15:18:20 +02:00
1 parent f4741e1bee
commit 59feca2f8e
22 files changed
+1015 -17

No files matched your search

+120 -1
View File
@@ -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')]),
);
}
}
+22
View File
@@ -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;
}
}
+43 -3
View File
@@ -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')]),
);
}
}