From 59feca2f8ec880596f7d42455ffdb06f5950ecde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Sun, 13 Sep 2026 15:18:20 +0200 Subject: [PATCH] feat: add the booking board with floor plan and calendar --- assets/icons/lucide/chevron-left.svg | 1 + assets/icons/lucide/chevron-right.svg | 1 + .../AlreadyHoldingResourceException.php | 22 ++ src/Booking/BoardPresenter.php | 96 +++++++ src/Booking/BookingManager.php | 60 +++++ src/Booking/DateParam.php | 31 +++ .../ResourceAlreadyBookedException.php | 21 ++ src/Controller/BookingController.php | 121 ++++++++- src/Controller/CurrentUserTrait.php | 22 ++ src/Controller/DashboardController.php | 46 +++- src/Form/BookingRequest.php | 21 ++ src/Form/BookingType.php | 30 +++ src/Repository/BookingRepository.php | 20 ++ templates/_partials/_flashes.html.twig | 19 +- templates/_partials/_nav.html.twig | 14 ++ templates/booking/_modal_create.html.twig | 40 +++ templates/booking/index.html.twig | 32 +++ templates/dashboard/_calendar.html.twig | 71 ++++++ templates/dashboard/_floorplan.html.twig | 93 +++++++ templates/dashboard/index.html.twig | 27 +- templates/layout.html.twig | 8 +- tests/Controller/BookingControllerTest.php | 236 ++++++++++++++++++ 22 files changed, 1015 insertions(+), 17 deletions(-) create mode 100644 assets/icons/lucide/chevron-left.svg create mode 100644 assets/icons/lucide/chevron-right.svg create mode 100644 src/Booking/AlreadyHoldingResourceException.php create mode 100644 src/Booking/BoardPresenter.php create mode 100644 src/Booking/BookingManager.php create mode 100644 src/Booking/DateParam.php create mode 100644 src/Booking/ResourceAlreadyBookedException.php create mode 100644 src/Controller/CurrentUserTrait.php create mode 100644 src/Form/BookingRequest.php create mode 100644 src/Form/BookingType.php create mode 100644 templates/booking/_modal_create.html.twig create mode 100644 templates/dashboard/_calendar.html.twig create mode 100644 templates/dashboard/_floorplan.html.twig create mode 100644 tests/Controller/BookingControllerTest.php diff --git a/assets/icons/lucide/chevron-left.svg b/assets/icons/lucide/chevron-left.svg new file mode 100644 index 0000000..93f1e3c --- /dev/null +++ b/assets/icons/lucide/chevron-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/lucide/chevron-right.svg b/assets/icons/lucide/chevron-right.svg new file mode 100644 index 0000000..c2d3c5f --- /dev/null +++ b/assets/icons/lucide/chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Booking/AlreadyHoldingResourceException.php b/src/Booking/AlreadyHoldingResourceException.php new file mode 100644 index 0000000..4212025 --- /dev/null +++ b/src/Booking/AlreadyHoldingResourceException.php @@ -0,0 +1,22 @@ +getDate()->format('d.m.Y'), + $existing->getResource()->getLabel(), + )); + } +} diff --git a/src/Booking/BoardPresenter.php b/src/Booking/BoardPresenter.php new file mode 100644 index 0000000..c838719 --- /dev/null +++ b/src/Booking/BoardPresenter.php @@ -0,0 +1,96 @@ + + */ + 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> $mine + * + * @return list + */ + private function mineOn(\DateTimeImmutable $date, array $mine): array + { + return $mine[$date->format('Y-m-d')] ?? []; + } +} diff --git a/src/Booking/BookingManager.php b/src/Booking/BookingManager.php new file mode 100644 index 0000000..1bf1fa6 --- /dev/null +++ b/src/Booking/BookingManager.php @@ -0,0 +1,60 @@ +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(); + } +} diff --git a/src/Booking/DateParam.php b/src/Booking/DateParam.php new file mode 100644 index 0000000..b328733 --- /dev/null +++ b/src/Booking/DateParam.php @@ -0,0 +1,31 @@ +getLabel(), $date->format('d.m.Y')), + previous: $previous, + ); + } +} diff --git a/src/Controller/BookingController.php b/src/Controller/BookingController.php index 1eb8693..d98296b 100644 --- a/src/Controller/BookingController.php +++ b/src/Controller/BookingController.php @@ -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')]), + ); } } diff --git a/src/Controller/CurrentUserTrait.php b/src/Controller/CurrentUserTrait.php new file mode 100644 index 0000000..b0bdc5f --- /dev/null +++ b/src/Controller/CurrentUserTrait.php @@ -0,0 +1,22 @@ +getUser(); + \assert($user instanceof User); + + return $user; + } +} diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 154932b..f9d16e0 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -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')]), + ); } } diff --git a/src/Form/BookingRequest.php b/src/Form/BookingRequest.php new file mode 100644 index 0000000..0b59057 --- /dev/null +++ b/src/Form/BookingRequest.php @@ -0,0 +1,21 @@ +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, + ]); + } +} diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php index 91e988b..6febeca 100644 --- a/src/Repository/BookingRepository.php +++ b/src/Repository/BookingRepository.php @@ -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(); + } } diff --git a/templates/_partials/_flashes.html.twig b/templates/_partials/_flashes.html.twig index c69c24b..c9a7ed5 100644 --- a/templates/_partials/_flashes.html.twig +++ b/templates/_partials/_flashes.html.twig @@ -1,13 +1,12 @@ -{# Flat inline alerts for now. myep renders these as Toastify toasts, but that means adopting - toastify-js as a dependency -- deferred until the toast/OOB work (plan step 7). #} -{% set levels = { error: 'bg-red-600', warning: 'bg-red-600', success: 'bg-emerald-600', info: 'bg-primary' } %} +{# Just the alerts. The positioned container with id="flashes" lives in layout.html.twig, because + that is the element htmx swaps out of band after a booking. Flat inline alerts for now: myep + renders these as Toastify toasts, which would mean adopting toastify-js (plan step 2/7). #} +{% set levels = { error: 'bg-red-600', warning: 'bg-amber-600', success: 'bg-emerald-600', info: 'bg-primary' } %} {% for level, messages in app.flashes(levels|keys) %} -
- {% for message in messages %} -
- {{ message|trans }} -
- {% endfor %} -
+ {% for message in messages %} +
+ {{ message|trans }} +
+ {% endfor %} {% endfor %} diff --git a/templates/_partials/_nav.html.twig b/templates/_partials/_nav.html.twig index 868d963..f47c192 100644 --- a/templates/_partials/_nav.html.twig +++ b/templates/_partials/_nav.html.twig @@ -21,5 +21,19 @@ {% endfor %} + + {% if app.user %} + + {% endif %} diff --git a/templates/booking/_modal_create.html.twig b/templates/booking/_modal_create.html.twig new file mode 100644 index 0000000..7e371fc --- /dev/null +++ b/templates/booking/_modal_create.html.twig @@ -0,0 +1,40 @@ +{# + The booking modal. Extending htmx_modal.html.twig keeps it a standalone fragment -- it is a + valid response on its own, which is what the opener's hx-swap="beforeend" onto needs. + + The form targets the modal itself with outerHTML, which does double duty: + * invalid submit -> Symfony returns 422, htmx swaps it (see responseHandling in app.js) and + the modal replaces itself with the errors showing; + * success -> the response is nothing but out-of-band board blocks, so the modal is replaced + with an empty fragment, i.e. it closes. +#} +{% extends 'htmx_modal.html.twig' %} + +{% block title %}{{ resource.label }} buchen{% endblock %} + +{% block content %} + {{ form_start(form, { attr: { + 'hx-post': form.vars.action, + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } }) }} + {# Root-level errors, which is where a rejected booking lands -- form_end/form_rest does + NOT render these, so leaving this out makes the 422 path silently blank. #} + {{ form_errors(form) }} + +

+ {{ resource.type.label }} · {{ resource.label }} +

+ + {{ form_row(form.date) }} + +
+ + +
+ {{ form_end(form) }} +{% endblock %} diff --git a/templates/booking/index.html.twig b/templates/booking/index.html.twig index f0b2bbb..4f53909 100644 --- a/templates/booking/index.html.twig +++ b/templates/booking/index.html.twig @@ -4,4 +4,36 @@ {% block content %}

Meine Buchungen

+

Alles, was ab heute für dich reserviert ist.

+ + {% if bookings is empty %} +

+ Noch nichts gebucht. Zum Raumplan. +

+ {% else %} +
    + {% for booking in bookings %} +
  • +
    +
    + {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }} +
    +
    + {{ booking.resource.type.label }} · {{ booking.resource.label }} +
    +
    +
    + Im Plan + {# Ordinary form post: this page has no board fragments to swap, so it + reloads afterwards rather than pretending to be htmx. #} +
    + + +
    +
    +
  • + {% endfor %} +
+ {% endif %} {% endblock %} diff --git a/templates/dashboard/_calendar.html.twig b/templates/dashboard/_calendar.html.twig new file mode 100644 index 0000000..fda65aa --- /dev/null +++ b/templates/dashboard/_calendar.html.twig @@ -0,0 +1,71 @@ +{# Server-rendered month grid. No calendar library: prev/next and day selection are plain hx-get + requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #} +{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %} + +
+
+ +
+ {{ month_start|format_datetime(pattern: 'LLLL y') }} +
+ +
+ +
+ {% for weekday in weekdays %}
{{ weekday }}
{% endfor %} +
+ +
+ {% for week in weeks %} + {% for day in week %} + {% set classes = 'relative h-12 rounded-md border text-sm flex flex-col items-center justify-center' %} + {% if day.is_selected %} + {% set classes = classes ~ ' border-primary bg-primary text-white font-bold' %} + {% elseif not day.in_month %} + {% set classes = classes ~ ' border-transparent text-gray-300' %} + {% elseif day.is_weekend %} + {% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %} + {% else %} + {% set classes = classes ~ ' border-gray-200 hover:bg-gray-50' %} + {% endif %} + {% if day.is_today and not day.is_selected %} + {% set classes = classes ~ ' ring-1 ring-primary' %} + {% endif %} + + + {% endfor %} + {% endfor %} +
+ +
+
+ eigene Buchung +
+
Zahl = belegte Plätze an dem Tag
+
+
diff --git a/templates/dashboard/_floorplan.html.twig b/templates/dashboard/_floorplan.html.twig new file mode 100644 index 0000000..f990980 --- /dev/null +++ b/templates/dashboard/_floorplan.html.twig @@ -0,0 +1,93 @@ +{# + Hand-authored floor plan. The geometry below is the drawing; `svgElementId` on each Resource + is the key that ties a database row to a shape here. A resource whose id is missing from the + map is simply not drawn (and vice versa), which is why app:seed and this map have to agree. + + Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day, + so free/booked/yours is decided in Twig. +#} +{% set desk_plan = { + 'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 }, + 'desk-04': { x: 50, y: 170 }, 'desk-05': { x: 190, y: 170 }, 'desk-06': { x: 330, y: 170 }, + 'desk-07': { x: 50, y: 270 }, 'desk-08': { x: 190, y: 270 }, 'desk-09': { x: 330, y: 270 }, + 'desk-10': { x: 50, y: 370 }, 'desk-11': { x: 190, y: 370 }, 'desk-12': { x: 330, y: 370 } +} %} +{% set parking_plan = { + 'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 }, + 'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 } +} %} + +{% macro spot(resource, geometry, width, height, booking, selected_date, is_past) %} + {% set mine = booking is not null and booking.user.id == app.user.id %} + {% if mine %} + {% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %} + {% elseif booking is not null %} + {% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %} + {% else %} + {% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %} + {% endif %} + + {% set interactive = not is_past and (mine or booking is null) %} + + ; closing it is Stimulus, not htmx. #} + hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }) }}" + hx-target="body" + hx-swap="beforeend" + {%- endif %}> + + {{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%} + + + {{ resource.label }} + {% if booking %} + {{ mine ? 'du' : booking.user.name }} + {% endif %} + +{% endmacro %} + +
+
+

{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}

+
+ frei + belegt + deine +
+
+ + {% if is_past %} +

Vergangener Tag — hier lässt sich nichts mehr buchen.

+ {% endif %} + + + + Büro + + + Parkplatz + + {% for resource in desks %} + {% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %} + {{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70, + bookings_by_resource[resource.id]|default(null), selected_date, is_past) }} + {% endif %} + {% endfor %} + + {% for resource in parking %} + {% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %} + {{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50, + bookings_by_resource[resource.id]|default(null), selected_date, is_past) }} + {% endif %} + {% endfor %} + +
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig index cf05999..786593f 100644 --- a/templates/dashboard/index.html.twig +++ b/templates/dashboard/index.html.twig @@ -3,6 +3,29 @@ {% block title %}Übersicht · ephub{% endblock %} {% block content %} -

Übersicht

-

Schreibtische und Parkplätze buchen.

+

Buchen

+

Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.

+ +
+
+ {# + The two blocks below are what DashboardController::day and BookingController + swap out of band. Each one owns a stable id and emits hx-swap-oob only when + rendered through htmxOobResponse -- rendered normally they are plain markup. + #} + {% block calendar %} +
+ {% include 'dashboard/_calendar.html.twig' %} +
+ {% endblock %} +
+ +
+ {% block floorplan %} +
+ {% include 'dashboard/_floorplan.html.twig' %} +
+ {% endblock %} +
+
{% endblock %} diff --git a/templates/layout.html.twig b/templates/layout.html.twig index af26912..631414f 100644 --- a/templates/layout.html.twig +++ b/templates/layout.html.twig @@ -39,5 +39,11 @@ - {% include '_partials/_flashes.html.twig' %} + {# Swapped out of band after a booking, hence the stable id and the conditional attribute. #} + {% block flashes %} +
+ {% include '_partials/_flashes.html.twig' %} +
+ {% endblock %} {% endblock %} diff --git a/tests/Controller/BookingControllerTest.php b/tests/Controller/BookingControllerTest.php new file mode 100644 index 0000000..e4b4d9a --- /dev/null +++ b/tests/Controller/BookingControllerTest.php @@ -0,0 +1,236 @@ +client = self::createClient(); + $this->client->disableReboot(); + $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testPickingADayReturnsNothingButOutOfBandBlocks(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $this->client->request('GET', '/tag/2026-10-01', server: ['HTTP_HX-Request' => 'true']); + $response = $this->client->getResponse(); + + self::assertResponseIsSuccessful(); + self::assertSame('/?tag=2026-10-01', $response->headers->get('HX-Push-Url')); + self::assertSame(2, substr_count((string) $response->getContent(), 'hx-swap-oob="true"')); + self::assertStringContainsString('id="board-calendar"', (string) $response->getContent()); + self::assertStringContainsString('id="board-floorplan"', (string) $response->getContent()); + } + + public function testTheDayRouteRedirectsWhenItIsNotAnHtmxRequest(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $this->client->request('GET', '/tag/2026-10-01'); + + self::assertResponseRedirects('/?tag=2026-10-01'); + } + + /** + * Verification step 6 of the plan: the CSRF token is an ordinary session-backed form field, + * so an htmx submit carrying it passes without any header bridge. + */ + public function testBookingAFreeDeskPassesCsrfAndSwapsTheBoard(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + + $response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01'); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + // The modal is gone from the response, which is what closes it: an out-of-band-only + // body leaves the outerHTML target with nothing to swap in. + self::assertStringNotContainsString('id="htmx-modal"', (string) $response->getContent()); + self::assertSame(3, substr_count((string) $response->getContent(), 'hx-swap-oob="true"')); + self::assertNotNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk])); + } + + /** + * Verification step 7: this is the path that silently does nothing if the 422 entry is + * missing from htmx's responseHandling. + */ + public function testAnInvalidSubmitRerendersTheModalWith422(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01'); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent()); + self::assertStringContainsString('Für vergangene Tage kann nicht gebucht werden.', (string) $response->getContent()); + } + + /** + * Verification step 8: one success and one handled inline error, not a 500. + */ + public function testADeskAlreadyTakenIsReportedInsideTheModal(): void + { + $desk = $this->resource('D1'); + $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + + $response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01'); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent()); + } + + public function testOnePersonCannotHoldTwoDesksOnTheSameDay(): void + { + $anna = $this->user('anna@ep-reisen.de'); + $this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + + $this->client->loginUser($anna); + + $response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01'); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); + self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent()); + } + + public function testADeskAndAParkingSpotOnTheSameDayAreBothAllowed(): void + { + $anna = $this->user('anna@ep-reisen.de'); + $this->em->persist(new Booking($anna, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'))); + $this->em->flush(); + + $this->client->loginUser($anna); + + $response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01'); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testAnInvalidCsrfTokenIsRejected(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $desk = $this->resource('D1'); + + $this->client->request('POST', \sprintf('/buchungen/neu/%d/2026-10-01', $desk->getId()), [ + 'booking' => ['date' => '2026-10-01', '_token' => 'nope'], + ], server: ['HTTP_HX-Request' => 'true']); + + self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode()); + self::assertNull($this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk])); + } + + /** + * Note the status is 200, not 403: HtmxExceptionListener deliberately converts every + * exception on an htmx request -- access denied included -- into a visible error modal, + * because htmx discards a 4xx body and the click would otherwise do nothing at all. + * What matters here is that the booking survives. + */ + public function testReleasingAnotherPersonsBookingIsRefused(): void + { + $booking = new Booking($this->user('bea@ep-reisen.de'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01')); + $this->em->persist($booking); + $this->em->flush(); + + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $booking->getId()), server: ['HTTP_HX-Request' => 'true']); + + $response = $this->client->getResponse(); + self::assertSame('body', $response->headers->get('HX-Retarget')); + self::assertNotNull($this->em->getRepository(Booking::class)->find($booking->getId())); + } + + public function testReleasingOwnBookingSwapsTheBoard(): void + { + $anna = $this->user('anna@ep-reisen.de'); + $booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01')); + $this->em->persist($booking); + $this->em->flush(); + $id = $booking->getId(); + + $this->client->loginUser($anna); + + // Read the token off the rendered plan rather than minting one: outside a request there + // is no session for the token storage to live in, and this is the real round trip anyway. + $crawler = $this->client->request('GET', '/?tag=2026-10-01'); + $vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals'); + $token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; + + $this->client->request('POST', \sprintf('/buchungen/%d/freigeben', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']); + + self::assertResponseIsSuccessful(); + self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"')); + self::assertNull($this->em->getRepository(Booking::class)->find($id)); + } + + private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response + { + $crawler = $this->client->request( + 'GET', + \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate), + server: ['HTTP_HX-Request' => 'true'], + ); + self::assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]); + $this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']); + + return $this->client->getResponse(); + } + + private function user(string $email, string $name = 'Test'): User + { + $user = new User($email, $name); + $user->setRoles(['ROLE_EMPLOYEE']); + $this->em->persist($user); + $this->em->flush(); + + return $user; + } + + /** + * Note $svgElementId: the floor plan only draws resources whose id appears in the geometry + * map in _floorplan.html.twig, so a test that needs to click a shape must use a real one. + */ + private function resource(string $label, ResourceType $type = ResourceType::DESK, ?string $svgElementId = null): Resource + { + $resource = new Resource($type, $label, $svgElementId); + $this->em->persist($resource); + $this->em->flush(); + + return $resource; + } +}