feat: add admin views, a booking horizon and confirmation modals

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5 committed 2026-09-14 09:40:17 +02:00
1 parent 8caf618950
commit 1a3640b15c
27 files changed
+1269 -62

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Booking;
/**
* How far ahead a booking may be made.
*
* Measured from **today**, not from the day being booked: a cap relative to the chosen start date
* can be pushed out simply by starting later, which is exactly what the previous
* BookingSeries::MAX_MONTHS rule allowed (a series starting in six months could run to month
* nine). It governs single bookings and series alike, which is why it lives here rather than on
* BookingSeries.
*
* ROLE_ADMIN is exempt -- see BookingRequest::$unlimitedHorizon.
*/
final class Horizon
{
public const MONTHS = 3;
/**
* The last day that may still be booked. Inclusive.
*/
public static function latest(?\DateTimeImmutable $today = null): \DateTimeImmutable
{
return ($today ?? new \DateTimeImmutable('today'))
->setTime(0, 0)
->modify(\sprintf('+%d months', self::MONTHS));
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Booking\DateParam;
use App\Controller\BoardController;
use App\Controller\CurrentUserTrait;
use App\Entity\Resource;
use App\Form\Admin\BookingFilter;
use App\Form\Admin\BookingFilterType;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* What ROLE_ADMIN can do that ROLE_EMPLOYEE cannot: see everybody's bookings and free a resource
* on somebody's behalf -- a colleague calls in sick and their desk should go back into the pool.
*
* This controller is read-only. The two actions it offers post to the ordinary
* app_booking_release / app_series_end routes, whose ownership checks were widened to admit an
* administrator, so there is exactly one code path that removes a booking and it still goes
* through BookingManager.
*
* The access_control rule on ^/admin is the real gate; the attribute here is so the guard
* survives a change to the route path.
*/
#[IsGranted(Role::ADMIN)]
final class BookingAdminController extends AbstractController
{
use CurrentUserTrait;
public function __construct(
private readonly BookingRepository $bookings,
private readonly BookingSeriesRepository $series,
) {
}
#[Route('/admin/bookings', name: 'app_admin_booking', methods: ['GET'])]
public function index(Request $request): Response
{
$filter = new BookingFilter();
$form = $this->createForm(BookingFilterType::class, $filter);
// A GET form submits itself by navigation, so handleRequest() reads the query string.
// Unfiltered first visits simply keep the constructor's defaults.
$form->handleRequest($request);
return $this->render('admin/bookings.html.twig', [
'form' => $form,
'filter' => $filter,
'bookings' => $this->bookings->findForAdmin($filter),
'series' => $this->series->findActive($filter->fromDate(), $filter->user, $filter->type),
]);
}
/**
* The modal behind an occupied shape on the floor plan, for an administrator.
*
* Not app_booking_edit: a shape can hold several bookings on one day once part-day spans
* exist, so "the booking" has no single referent -- and editing reschedules, which an
* administrator deliberately may not do to somebody else's booking.
*/
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
public function spot(Request $request, Resource $resource, string $date): Response
{
$selected = DateParam::parseOrToday($date);
return $this->render('admin/_modal_spot.html.twig', [
'resource' => $resource,
'date' => $selected,
// The board's span, so the modal lists exactly what the shape the user clicked shows.
// findForDate() rather than findOverlapping(): it eager-loads the holder, whose name
// is the point of this modal, and it is documented as the locked-only query's
// counterpart for reading. Filtering a dozen rows in PHP is cheaper than a second
// query shape to keep in sync.
'bookings' => array_values(array_filter(
$this->bookings->findForDate($selected, BoardController::timeOf($request), $resource->getType()),
static fn ($booking): bool => $booking->getResource()->getId() === $resource->getId(),
)),
]);
}
}
+117 -16
View File
@@ -8,6 +8,7 @@ use App\Booking\AlreadyHoldingResourceException;
use App\Booking\BoardPresenter;
use App\Booking\BookingManager;
use App\Booking\DateParam;
use App\Booking\Horizon;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\Spot;
use App\Booking\SeriesResult;
@@ -21,6 +22,8 @@ use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use App\Security\Role;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
@@ -38,6 +41,7 @@ final class BookingController extends AbstractController
private readonly BookingManager $bookings,
private readonly BookingRepository $repository,
private readonly BookingSeriesRepository $series,
private readonly LoggerInterface $logger,
) {
}
@@ -82,9 +86,13 @@ final class BookingController extends AbstractController
$this->currentUser(),
);
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
// An admin books on behalf of people and cleans up after the fact, so the three-month
// horizon that keeps desks from being blocked half a year ahead does not bind them.
$data->unlimitedHorizon = $this->isGranted(Role::ADMIN);
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
'horizon' => $data->unlimitedHorizon ? null : Horizon::latest(),
]);
$form->handleRequest($request);
@@ -122,6 +130,8 @@ final class BookingController extends AbstractController
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Booking $booking): Response
{
// Deliberately not widened for admins: rescheduling somebody else's booking is a
// different act from freeing a desk. Admins release; they do not move.
$this->denyUnlessOwn($booking);
$boardTime = BoardController::timeOf($request);
@@ -154,44 +164,95 @@ final class BookingController extends AbstractController
]);
}
#[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['POST'])]
/**
* GET renders the confirmation modal, POST does the release -- the sibling apps' shape for
* every destructive action (see templates/htmx_confirmation_modal.html.twig). The confirm
* button posts back to this same URI, so `?return=` survives the round trip on its own.
*/
#[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['GET', 'POST'])]
public function release(Request $request, Booking $booking): Response
{
$this->denyUnlessOwn($booking);
$this->denyUnlessMayManage($booking);
if (!$request->isMethod(Request::METHOD_POST)) {
return $this->render('booking/_modal_release.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'release-'.$booking->getId(),
]);
}
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
$date = $booking->getDate();
$type = $booking->getResource()->getType();
$label = $booking->getResource()->getLabel();
$this->bookings->release($booking);
$holder = $booking->getUser();
$onBehalf = !$booking->belongsTo($this->currentUser());
$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');
if ($onBehalf) {
// The row is about to be deleted, so without this line nothing records that an
// administrator freed somebody else's desk.
$this->logger->info('Admin released a booking on behalf of another user.', [
'admin' => $this->currentUser()->getEmail(),
'holder' => $holder->getEmail(),
'resource' => $label,
'date' => $date->format('Y-m-d'),
'time' => (string) $booking->getTimeRange(),
]);
}
return $this->boardResponse($type, $date, BoardController::timeOf($request));
$this->bookings->release($booking);
$this->addFlash('success', $onBehalf
? \sprintf('%s freigegeben (%s).', $label, $holder->getName())
: \sprintf('%s freigegeben.', $label));
// The two lists have no board fragments to swap, so they say where they want to end up
// and get a genuine navigation. Everything else came from the board and is swapped in
// place -- including the modal the confirmation was opened over.
if (null !== $this->returnRoute($request)) {
return $this->htmxRedirect($request, $this->generateUrl((string) $this->returnRoute($request)));
}
return $this->boardResponse($type, $date, BoardController::timeOf($request), dismissModal: true);
}
/**
* Ends a series from today onwards. Past occurrences are history and stay put.
*/
#[Route('/series/{id}/end', name: 'app_series_end', methods: ['POST'])]
#[Route('/series/{id}/end', name: 'app_series_end', methods: ['GET', 'POST'])]
public function endSeries(Request $request, BookingSeries $series): Response
{
if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it.');
$this->denyUnlessMayManageSeries($series);
if (!$request->isMethod(Request::METHOD_POST)) {
return $this->render('booking/_modal_end_series.html.twig', [
'series' => $series,
'csrf_token_id' => 'end-series-'.$series->getId(),
]);
}
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
$onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId();
if ($onBehalf) {
$this->logger->info('Admin ended a series on behalf of another user.', [
'admin' => $this->currentUser()->getEmail(),
'holder' => $series->getUser()->getEmail(),
'resource' => $series->getResource()->getLabel(),
'series' => $series->getId(),
]);
}
$removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today'));
$this->addFlash('success', \sprintf('Serie beendet, %d Termine entfernt.', $removed));
$this->addFlash('success', $onBehalf
? \sprintf('Serie von %s beendet, %d Termine entfernt.', $series->getUser()->getName(), $removed)
: \sprintf('Serie beendet, %d Termine entfernt.', $removed));
return $this->redirectToRoute('app_booking');
// A series is only ever ended from a list, so this always navigates.
return $this->htmxRedirect($request, $this->generateUrl($this->returnRoute($request) ?? 'app_booking'));
}
private function createSeries(BookingRequest $data, Resource $resource): SeriesResult
@@ -245,11 +306,18 @@ final class BookingController extends AbstractController
* 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(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): Response
private function boardResponse(ResourceType $type, \DateTimeImmutable $date, TimeRange $time, bool $dismissModal = false): Response
{
// The confirmation modal swaps *itself* away, which cannot reach the modal it was opened
// over -- so that one is removed out of band instead. Only on this path: on every other
// one the modal is the swap target and deleting it would leave the swap with nowhere to go.
$blocks = $dismissModal
? ['calendar', 'floorplan', 'flashes', 'modal_dismiss']
: ['calendar', 'floorplan', 'flashes'];
return $this->htmxOobResponse(
'board/index.html.twig',
['calendar', 'floorplan', 'flashes'],
$blocks,
$this->board->context($type, $date, $time, $this->currentUser()),
$this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
);
@@ -270,6 +338,39 @@ final class BookingController extends AbstractController
}
}
/**
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
* colleague calls in sick and their desk has to go back into the pool.
*/
private function denyUnlessMayManage(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
}
}
private function denyUnlessMayManageSeries(BookingSeries $series): void
{
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
}
}
/**
* Where to navigate after a confirmed action, or null when the caller is the board and wants
* its fragments swapped instead. The opener says which -- the admin screen sends
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
* bookings.
*/
private function returnRoute(Request $request): ?string
{
return match ($request->query->get('return')) {
'admin' => 'app_admin_booking',
'bookings' => 'app_booking',
default => null,
};
}
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
{
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
-3
View File
@@ -22,9 +22,6 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
class BookingSeries
{
/** A series may not run further out than this; desks are not booked years ahead. */
public const MAX_MONTHS = 12;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Booking\Horizon;
use App\Entity\ResourceType;
use App\Entity\User;
/**
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
* live in the URL and a filtered view can be linked to or bookmarked.
*/
final class BookingFilter
{
public ?\DateTimeImmutable $from = null;
public ?\DateTimeImmutable $to = null;
public ?ResourceType $type = null;
public ?User $user = null;
public function __construct()
{
// Today through the booking horizon: what an administrator can still do something about.
// Past bookings are history and are not actionable, so they are not the default view.
$this->from = new \DateTimeImmutable('today');
$this->to = Horizon::latest();
}
/**
* Defaults applied here rather than in the query, so the template can show the window it is
* actually listing even when a field was cleared.
*/
public function fromDate(): \DateTimeImmutable
{
return ($this->from ?? new \DateTimeImmutable('today'))->setTime(0, 0);
}
public function toDate(): \DateTimeImmutable
{
return ($this->to ?? $this->fromDate()->modify('+1 year'))->setTime(0, 0);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A GET form: submitting it is ordinary browser navigation, which is the house rule -- htmx owns
* fragment round trips, the browser owns navigation. No CSRF token for the same reason; there is
* nothing to protect in a read-only query string.
*/
final class BookingFilterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('from', DateType::class, [
'label' => 'Von',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('to', DateType::class, [
'label' => 'Bis',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
])
->add('type', EnumType::class, [
'label' => 'Art',
'class' => ResourceType::class,
'required' => false,
'placeholder' => 'Alle Arten',
'choice_label' => static fn (ResourceType $type): string => $type->plural(),
])
->add('user', EntityType::class, [
'label' => 'Person',
'class' => User::class,
'required' => false,
'placeholder' => 'Alle Personen',
'choice_label' => 'name',
'query_builder' => static fn (EntityRepository $repository) => $repository
->createQueryBuilder('u')
->orderBy('u.name', 'ASC'),
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => BookingFilter::class,
'method' => 'GET',
'csrf_protection' => false,
]);
}
/**
* No prefix, so the query string reads ?from=&to=&type=&user= rather than ?booking_filter[from]=.
*/
public function getBlockPrefix(): string
{
return '';
}
}
+30 -4
View File
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Form;
use App\Booking\Horizon;
use App\Booking\RecurrenceRule;
use App\Booking\TimeRange;
use App\Entity\BookingSeries;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -20,6 +20,13 @@ final class BookingRequest
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
public ?\DateTimeImmutable $date = null;
/**
* Lifts the Horizon cap for this request. Set by the controller for ROLE_ADMIN -- a DTO
* cannot reach the security container, and giving it one would make these rules untestable
* in isolation.
*/
public bool $unlimitedHorizon = false;
public bool $allDay = true;
public ?\DateTimeImmutable $startTime = null;
@@ -85,6 +92,8 @@ final class BookingRequest
}
}
$this->validateHorizon($context);
if (!$this->repeats()) {
return;
}
@@ -101,14 +110,31 @@ final class BookingRequest
return;
}
$latest = ($this->date ?? new \DateTimeImmutable('today'))->modify(\sprintf('+%d months', BookingSeries::MAX_MONTHS));
if ($this->until > $latest) {
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate laufen.', BookingSeries::MAX_MONTHS))
if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) {
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS))
->atPath('until')
->addViolation();
}
}
/**
* The booking horizon, measured from today rather than from the chosen day -- see Horizon.
* Not an attribute constraint because it has to be liftable per role, which an attribute
* cannot express.
*/
private function validateHorizon(ExecutionContextInterface $context): void
{
if ($this->unlimitedHorizon || null === $this->date) {
return;
}
if ($this->date > Horizon::latest()) {
$context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', Horizon::MONTHS))
->atPath('date')
->addViolation();
}
}
private static function timeOf(int $minutes): \DateTimeImmutable
{
// 24:00 has no representation as a time-of-day, so an all-day end never reaches here.
+11
View File
@@ -17,11 +17,19 @@ final class BookingType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// The native date picker refuses anything past the horizon by itself, so the server rule
// in BookingRequest is a backstop rather than the first thing a user meets. Null for an
// admin, who has no horizon.
$horizon = $options['horizon'] instanceof \DateTimeImmutable
? ['max' => $options['horizon']->format('Y-m-d')]
: [];
$builder
->add('date', DateType::class, [
'label' => 'Tag',
'widget' => 'single_text',
'input' => 'datetime_immutable',
'attr' => $horizon,
])
->add('allDay', CheckboxType::class, [
'label' => 'Ganzer Tag',
@@ -53,6 +61,7 @@ final class BookingType extends AbstractType
'widget' => 'single_text',
'input' => 'datetime_immutable',
'required' => false,
'attr' => $horizon,
]);
}
@@ -60,6 +69,8 @@ final class BookingType extends AbstractType
{
$resolver->setDefaults([
'data_class' => BookingRequest::class,
'horizon' => null,
]);
$resolver->setAllowedTypes('horizon', ['null', \DateTimeImmutable::class]);
}
}
+36
View File
@@ -10,6 +10,7 @@ use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Entity\ResourceType;
use App\Entity\User;
use App\Form\Admin\BookingFilter;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
@@ -169,6 +170,41 @@ class BookingRepository extends ServiceEntityRepository
return $counts;
}
/**
* Every booking in a window, across everybody -- what the admin list renders.
*
* findForUserInRange() is the same query with a mandatory user; this one keeps it optional
* and eager-selects the holder as well, because the admin list names a different person on
* every row and would otherwise fire a query per booking.
*
* @return list<Booking>
*/
public function findForAdmin(BookingFilter $filter): array
{
$qb = $this->createQueryBuilder('b')
->addSelect('u', 'r', 'g', 's')
->join('b.user', 'u')
->join('b.resource', 'r')
->leftJoin('r.group', 'g')
->leftJoin('b.series', 's')
->andWhere('b.date BETWEEN :from AND :to')
->setParameter('from', $filter->fromDate())
->setParameter('to', $filter->toDate())
->orderBy('b.date', 'ASC')
->addOrderBy('b.startMinute', 'ASC')
->addOrderBy('r.label', 'ASC');
if (null !== $filter->user) {
$qb->andWhere('b.user = :user')->setParameter('user', $filter->user);
}
if (null !== $filter->type) {
$qb->andWhere('r.type = :type')->setParameter('type', $filter->type);
}
return $qb->getQuery()->getResult();
}
/**
* The occurrences an "end this series" would remove.
*
+30 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookingSeries;
use App\Entity\ResourceType;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -24,15 +25,38 @@ class BookingSeriesRepository extends ServiceEntityRepository
*/
public function findActiveForUser(User $user, \DateTimeImmutable $on): array
{
return $this->createQueryBuilder('s')
->addSelect('r')
return $this->findActive($on, $user);
}
/**
* Every series still running on the given day, optionally narrowed to one person or one kind
* of resource. The admin list is the caller that needs it across everybody.
*
* The user is eager-selected because the admin list names the holder of every row -- without
* it the page fires a query per series.
*
* @return list<BookingSeries>
*/
public function findActive(\DateTimeImmutable $on, ?User $user = null, ?ResourceType $type = null): array
{
$qb = $this->createQueryBuilder('s')
->addSelect('u', 'r', 'g')
->join('s.user', 'u')
->join('s.resource', 'r')
->andWhere('s.user = :user')
->leftJoin('r.group', 'g')
->andWhere('s.endDate >= :on')
->setParameter('user', $user)
->setParameter('on', $on->setTime(0, 0))
->orderBy('s.startDate', 'ASC')
->getQuery()
->getResult();
->addOrderBy('u.name', 'ASC');
if (null !== $user) {
$qb->andWhere('s.user = :user')->setParameter('user', $user);
}
if (null !== $type) {
$qb->andWhere('r.type = :type')->setParameter('type', $type);
}
return $qb->getQuery()->getResult();
}
}