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

@@ -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'))) {