feat: add admin views, a booking horizon and confirmation modals
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
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(),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'))) {
|
||||
|
||||
Reference in new issue
Block a user