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,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(),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'))) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user