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
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m15 18l-6-6l6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 191 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m9 18l6-6l-6-6"/></svg>
|
||||
|
After Width: | Height: | Size: 190 B |
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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')] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) %}
|
||||
<div class="fixed bottom-4 right-4 z-50 space-y-2">
|
||||
{% for message in messages %}
|
||||
<div class="{{ levels[level] }} text-gray-100 rounded shadow-lg px-4 py-3 text-sm">
|
||||
{{ message|trans }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for message in messages %}
|
||||
<div class="{{ levels[level] }} text-white rounded shadow-lg px-4 py-3 text-sm">
|
||||
{{ message|trans }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
@@ -21,5 +21,19 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if app.user %}
|
||||
<div class="mt-auto pt-6 border-t border-gray-200">
|
||||
<div class="px-2 pb-2 text-sm">
|
||||
<div class="font-semibold">{{ app.user.name }}</div>
|
||||
<div class="text-gray-500 truncate">{{ app.user.email }}</div>
|
||||
</div>
|
||||
<a href="{{ path('app_logout') }}"
|
||||
class="group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold text-gray-700 hover:bg-gray-50">
|
||||
{{ ux_icon('lucide:log-out', { class: 'h-5 w-5 shrink-0 text-gray-400' }) }}
|
||||
<span class="flex-1">Abmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
@@ -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 <body> 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) }}
|
||||
|
||||
<p class="pb-4 text-sm text-gray-500">
|
||||
{{ resource.type.label }} · {{ resource.label }}
|
||||
</p>
|
||||
|
||||
{{ form_row(form.date) }}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="button button--primary">
|
||||
Buchen
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -4,4 +4,36 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>Meine Buchungen</h1>
|
||||
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="mt-8 text-sm text-gray-500">
|
||||
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="mt-8 divide-y divide-gray-200 border-y border-gray-200">
|
||||
{% for booking in bookings %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a class="text-sm underline"
|
||||
href="{{ path('app_dashboard', { tag: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
||||
{# Ordinary form post: this page has no board fragments to swap, so it
|
||||
reloads afterwards rather than pretending to be htmx. #}
|
||||
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('release-' ~ booking.id) }}">
|
||||
<button type="submit" class="button button--small button--neutral">Freigeben</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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'] %}
|
||||
|
||||
<div class="rounded-md border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<button type="button"
|
||||
class="p-1 rounded-md hover:bg-gray-100"
|
||||
aria-label="Voriger Monat"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }) }}"
|
||||
hx-swap="none">
|
||||
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
|
||||
</button>
|
||||
<div class="font-bold">
|
||||
{{ month_start|format_datetime(pattern: 'LLLL y') }}
|
||||
</div>
|
||||
<button type="button"
|
||||
class="p-1 rounded-md hover:bg-gray-100"
|
||||
aria-label="Nächster Monat"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }) }}"
|
||||
hx-swap="none">
|
||||
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs text-gray-500 pb-1">
|
||||
{% for weekday in weekdays %}<div>{{ weekday }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
{% 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 %}
|
||||
|
||||
<button type="button"
|
||||
class="{{ classes }}"
|
||||
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }) }}"
|
||||
hx-swap="none">
|
||||
<span>{{ day.date|date('j') }}</span>
|
||||
{# Team view: the occupancy count is the whole point of showing the month. #}
|
||||
<span class="text-[10px] {{ day.is_selected ? 'text-white/80' : 'text-gray-400' }}">
|
||||
{{ day.count > 0 ? day.count : '' }}
|
||||
</span>
|
||||
{% if day.mine is not empty %}
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full {{ day.is_selected ? 'bg-white' : 'bg-primary' }}"></span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="pt-4 text-xs text-gray-500 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
|
||||
</div>
|
||||
<div>Zahl = belegte Plätze an dem Tag</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) %}
|
||||
|
||||
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive and mine %}
|
||||
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
|
||||
hx-confirm="{{ resource.label }} wieder freigeben?"
|
||||
hx-swap="none"
|
||||
{%- elseif interactive %}
|
||||
{# The modal is appended to the end of <body>; 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 %}>
|
||||
<title>
|
||||
{{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%}
|
||||
</title>
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (booking ? height / 2 - 4 : height / 2 + 4) }}"
|
||||
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
|
||||
{% if booking %}
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
|
||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ mine ? 'du' : booking.user.name }}</text>
|
||||
{% endif %}
|
||||
</g>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="rounded-md border border-gray-200 p-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-4">
|
||||
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-gray-200 border-2 border-gray-400"></span> belegt</span>
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-primary border-2 border-primary-dark"></span> deine</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if is_past %}
|
||||
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
|
||||
{% endif %}
|
||||
|
||||
<svg viewBox="0 0 810 480" class="w-full h-auto" role="img" aria-label="Raumplan">
|
||||
<rect x="20" y="30" width="470" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="30" y="22" class="fill-gray-400 text-xs font-semibold">Büro</text>
|
||||
|
||||
<rect x="520" y="30" width="270" height="420" rx="8" class="fill-white stroke-gray-300" stroke-width="2"></rect>
|
||||
<text x="530" y="22" class="fill-gray-400 text-xs font-semibold">Parkplatz</text>
|
||||
|
||||
{% 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 %}
|
||||
</svg>
|
||||
</div>
|
||||
@@ -3,6 +3,29 @@
|
||||
{% block title %}Übersicht · ephub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Übersicht</h1>
|
||||
<p class="subline">Schreibtische und Parkplätze buchen.</p>
|
||||
<h1>Buchen</h1>
|
||||
<p class="subline">Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.</p>
|
||||
|
||||
<div class="mt-8 grid gap-8 xl:grid-cols-3">
|
||||
<div class="xl:col-span-1">
|
||||
{#
|
||||
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 %}
|
||||
<div id="board-calendar"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||
{% include 'dashboard/_calendar.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-2">
|
||||
{% block floorplan %}
|
||||
<div id="board-floorplan"{% if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||
{% include 'dashboard/_floorplan.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -39,5 +39,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
{# Swapped out of band after a booking, hence the stable id and the conditional attribute. #}
|
||||
{% block flashes %}
|
||||
<div id="flashes" class="fixed bottom-4 right-4 z-50 space-y-2"
|
||||
{%- if htmx_oob_swap is defined and htmx_oob_swap %} hx-swap-oob="true"{% endif %}>
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Drives the htmx booking flow through the real kernel.
|
||||
*
|
||||
* Rebooting is disabled so the whole test stays on one connection and the surrounding
|
||||
* transaction can be rolled back -- with the default reboot-per-request the connection would be
|
||||
* recreated and the rollback would take nothing with it.
|
||||
*/
|
||||
class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
private KernelBrowser $client;
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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('[email protected]'));
|
||||
|
||||
$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('[email protected]'));
|
||||
|
||||
$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('[email protected]'));
|
||||
$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('[email protected]'));
|
||||
|
||||
$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('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01')));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$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('[email protected]');
|
||||
$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('[email protected]');
|
||||
$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('[email protected]'));
|
||||
$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('[email protected]'), $this->resource('D1'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$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('[email protected]');
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user