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

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
/**
* One person may hold at most one desk and one parking spot per day.
*/
final class AlreadyHoldingResourceException extends \RuntimeException
{
public function __construct(public readonly Booking $existing)
{
parent::__construct(\sprintf(
'Du hast am %s bereits %s gebucht.',
$existing->getDate()->format('d.m.Y'),
$existing->getResource()->getLabel(),
));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Repository\BookingRepository;
use App\Repository\ResourceRepository;
/**
* Builds the context behind the two fragments that make up the booking board: the month
* calendar and the floor plan.
*
* Both are always rendered from the same context so that one htmx round trip can swap both --
* picking a day updates the grid and the plan in a single out-of-band response.
*/
final readonly class BoardPresenter
{
private const WEEKS = 6;
public function __construct(
private BookingRepository $bookings,
private ResourceRepository $resources,
) {
}
/**
* @return array<string, mixed>
*/
public function context(\DateTimeImmutable $selected, User $user): array
{
$selected = $selected->setTime(0, 0);
$today = new \DateTimeImmutable('today');
$monthStart = $selected->modify('first day of this month');
$gridStart = $monthStart->modify('monday this week');
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd);
$mine = [];
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) {
$mine[$booking->getDate()->format('Y-m-d')][] = $booking;
}
$weeks = [];
$cursor = $gridStart;
for ($week = 0; $week < self::WEEKS; ++$week) {
$days = [];
for ($weekday = 0; $weekday < 7; ++$weekday) {
$key = $cursor->format('Y-m-d');
$days[] = [
'date' => $cursor,
'in_month' => $cursor->format('Y-m') === $monthStart->format('Y-m'),
'is_today' => $cursor == $today,
'is_selected' => $cursor == $selected,
'is_past' => $cursor < $today,
'is_weekend' => (int) $cursor->format('N') >= 6,
'count' => $counts[$key] ?? 0,
'mine' => $mine[$key] ?? [],
];
$cursor = $cursor->modify('+1 day');
}
$weeks[] = $days;
}
$byResource = [];
foreach ($this->bookings->findForDate($selected) as $booking) {
$byResource[$booking->getResource()->getId()] = $booking;
}
return [
'selected_date' => $selected,
'month_start' => $monthStart,
'weeks' => $weeks,
'is_past' => $selected < $today,
'desks' => $this->resources->findActiveByType(ResourceType::DESK),
'parking' => $this->resources->findActiveByType(ResourceType::PARKING),
'bookings_by_resource' => $byResource,
'my_bookings_today' => $this->mineOn($selected, $mine),
];
}
/**
* @param array<string, list<Booking>> $mine
*
* @return list<Booking>
*/
private function mineOn(\DateTimeImmutable $date, array $mine): array
{
return $mine[$date->format('Y-m-d')] ?? [];
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Booking;
use App\Entity\Resource;
use App\Entity\User;
use App\Repository\BookingRepository;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
final readonly class BookingManager
{
public function __construct(
private EntityManagerInterface $em,
private BookingRepository $bookings,
) {
}
/**
* @throws AlreadyHoldingResourceException if the user already holds a resource of this type that day
* @throws ResourceAlreadyBookedException if somebody else holds this resource that day
*/
public function book(User $user, Resource $resource, \DateTimeImmutable $date): Booking
{
$date = $date->setTime(0, 0);
$existing = $this->bookings->findOneForUserDateAndType($user, $date, $resource->getType());
if (null !== $existing) {
throw new AlreadyHoldingResourceException($existing);
}
// Friendly path: almost every collision is caught here, with the entity manager healthy.
if (null !== $this->bookings->findOneBy(['resource' => $resource, 'date' => $date])) {
throw new ResourceAlreadyBookedException($resource, $date);
}
$booking = new Booking($user, $resource, $date);
$this->em->persist($booking);
try {
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
// The real guard: two concurrent requests both pass the check above and the database
// rejects the second. A failed flush CLOSES the entity manager, so whoever catches
// this must render its error page without touching the database again.
throw new ResourceAlreadyBookedException($resource, $date, $e);
}
return $booking;
}
public function release(Booking $booking): void
{
$this->em->remove($booking);
$this->em->flush();
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Booking;
/**
* The Y-m-d day identifier that every board route speaks.
*/
final class DateParam
{
/** Route requirement, so a malformed day 404s instead of silently falling back to today. */
public const PATTERN = '\d{4}-\d{2}-\d{2}';
public static function parse(?string $value): ?\DateTimeImmutable
{
if (null === $value || '' === $value) {
return null;
}
// The leading "!" resets the time part, so the result is midnight rather than "now".
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return false === $date ? null : $date;
}
public static function parseOrToday(?string $value): \DateTimeImmutable
{
return self::parse($value) ?? new \DateTimeImmutable('today');
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Booking;
use App\Entity\Resource;
final class ResourceAlreadyBookedException extends \RuntimeException
{
public function __construct(
public readonly Resource $resource,
public readonly \DateTimeImmutable $date,
?\Throwable $previous = null,
) {
parent::__construct(
\sprintf('%s ist am %s bereits belegt.', $resource->getLabel(), $date->format('d.m.Y')),
previous: $previous,
);
}
}
+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')]),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Validator\Constraints as Assert;
/**
* What the booking modal edits. The resource is fixed by the URL -- only the day is adjustable,
* because the modal is opened by clicking a resource on the floor plan.
*/
final class BookingRequest
{
public function __construct(
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
public ?\DateTimeImmutable $date = null,
) {
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class BookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('date', DateType::class, [
'label' => 'Tag',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'html5' => true,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingRequest::class,
]);
}
}
+20
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\Booking;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -90,4 +91,23 @@ class BookingRepository extends ServiceEntityRepository
{
return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]);
}
/**
* One person may hold at most one desk and one parking spot per day, so this is what the
* booking path checks before inserting.
*/
public function findOneForUserDateAndType(User $user, \DateTimeImmutable $date, ResourceType $type): ?Booking
{
return $this->createQueryBuilder('b')
->addSelect('r')
->join('b.resource', 'r')
->andWhere('b.user = :user')
->andWhere('b.date = :date')
->andWhere('r.type = :type')
->setParameter('user', $user)
->setParameter('date', $date->setTime(0, 0))
->setParameter('type', $type)
->getQuery()
->getOneOrNullResult();
}
}