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 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/><path d="m9 12l2 2l4-4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 370 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="m21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 264 B |
@@ -27,6 +27,9 @@ security:
|
||||
- { path: ^/(login|auth|logout)$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/auth/, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/dev/, roles: PUBLIC_ACCESS }
|
||||
# Before the catch-all, or ROLE_EMPLOYEE would match ^/ first and the admin area would be
|
||||
# open to everybody. Order is the whole guard here.
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# Either role opens the app -- access_control treats a list of roles as an OR. Not
|
||||
# ROLE_USER: myep authenticates far more people than work here, and someone who holds
|
||||
# neither of these is refused at login with an explanation rather than let in to an
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{#
|
||||
What an administrator sees when clicking an occupied shape on the floor plan.
|
||||
|
||||
Not the edit modal: a shape can hold several bookings on one day once part-day spans exist,
|
||||
so "the booking" has no single referent -- and an administrator releases rather than
|
||||
reschedules. Each row posts to the ordinary app_booking_release route, whose ownership check
|
||||
admits an admin, so there is still one code path that removes a booking.
|
||||
#}
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}{{ resource.label }} verwalten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="pb-4 text-sm text-gray-500">
|
||||
{{ resource.type.label }} · {{ date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
|
||||
{%- if resource.group %} · {{ resource.group.label }}{% endif %}
|
||||
</p>
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="text-sm text-gray-500">In diesem Zeitraum liegt hier keine Buchung.</p>
|
||||
{% else %}
|
||||
<ul class="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.user.name }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.timeRange }}
|
||||
{% if booking.partOfSeries %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{# Same as the edit modal: opens the shared confirmation modal over this one.
|
||||
Confirming repaints the board out of band and removes both. #}
|
||||
<button type="button" class="button button--small button--warning"
|
||||
hx-get="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Freigeben
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex justify-end pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{#
|
||||
Every booking, not just yours. The point of the screen is the "Freigeben" button on somebody
|
||||
else's row: when a colleague calls in sick, their desk has to go back into the pool.
|
||||
|
||||
Filtering is a plain GET form -- the house rule hands navigation to the browser. The two
|
||||
destructive actions open the shared confirmation modal via htmx and then navigate back here,
|
||||
which is what ?return=admin says; without it a confirmed release swaps board fragments this
|
||||
page does not have.
|
||||
#}
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Verwaltung · ephub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a class="text-sm text-gray-500 hover:text-gray-900" href="{{ path('app_dashboard') }}">← Übersicht</a>
|
||||
|
||||
<h1 class="pt-2">Verwaltung</h1>
|
||||
<p class="subline">Buchungen aller Mitarbeitenden.</p>
|
||||
|
||||
{{ form_start(form, { attr: { class: 'mt-6 rounded-md border border-gray-200 p-4' } }) }}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{{ form_row(form.from) }}
|
||||
{{ form_row(form.to) }}
|
||||
{{ form_row(form.type) }}
|
||||
{{ form_row(form.user) }}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<a class="button button--neutral" href="{{ path('app_admin_booking') }}">Zurücksetzen</a>
|
||||
<button type="submit" class="button button--primary">Filtern</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
|
||||
{% if series is not empty %}
|
||||
<h2 class="pt-8">Serien</h2>
|
||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||
{% for entry in series %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ entry.user.name }}
|
||||
<span class="font-normal text-gray-500">· {{ entry.resource.label }}</span>
|
||||
{%- if entry.resource.group %} <span class="font-normal text-gray-500">· {{ entry.resource.group.label }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_series_end', { id: entry.id, return: 'admin' }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Serie beenden
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="pt-8">Termine</h2>
|
||||
<p class="subline">
|
||||
{{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
|
||||
{{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</p>
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="text-sm text-gray-500">Keine Buchungen in diesem Zeitraum.</p>
|
||||
{% else %}
|
||||
<ul class="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.user.name }}
|
||||
<span class="font-normal text-gray-500">
|
||||
· {{ booking.date|format_datetime(pattern: 'EEE, d. MMM y') }}
|
||||
· {{ booking.timeRange }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||
{%- if booking.resource.group %} · {{ booking.resource.group.label }}{% endif %}
|
||||
{% if booking.partOfSeries %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
|
||||
{% endif %}
|
||||
{% if booking.overridden %}
|
||||
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a class="text-sm underline shrink-0"
|
||||
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'admin' }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Freigeben
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -37,6 +37,11 @@
|
||||
{% 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_past %}
|
||||
{# Dashed rather than the weekend's grey fill: the two states must not be
|
||||
confusable, and the day stays clickable -- looking back at a past plan is
|
||||
legitimate, only booking it is not. #}
|
||||
{% set classes = classes ~ ' border-dashed border-gray-200 text-gray-400' %}
|
||||
{% elseif day.is_weekend %}
|
||||
{% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %}
|
||||
{% else %}
|
||||
@@ -49,15 +54,16 @@
|
||||
<button type="button"
|
||||
class="{{ classes }}"
|
||||
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
||||
{%- if day.is_past %} aria-disabled="true" title="Vergangener Tag – keine Buchung mehr möglich"{% endif %}
|
||||
hx-get="{{ path('app_board_day', { type: type.value, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
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' }}">
|
||||
<span class="text-[10px] {{ day.is_selected ? 'text-white/80' : (day.is_past ? 'text-gray-300' : '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>
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full {{ day.is_selected ? 'bg-white' : (day.is_past ? 'bg-gray-300' : 'bg-primary') }}"></span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
@@ -68,6 +74,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
|
||||
</div>
|
||||
<div>gestrichelt = vergangen, nicht mehr buchbar</div>
|
||||
<div>Zahl = belegte Plätze{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,3 +32,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{#
|
||||
Removes whatever modal is open, out of band. Only BookingController::boardResponse() renders
|
||||
it, and only for a confirmation that was opened over another modal -- the confirmation swaps
|
||||
itself away by targeting its own element, which leaves the one underneath behind.
|
||||
|
||||
Never rendered as part of the page: nothing in the layout calls this block.
|
||||
#}
|
||||
{% block modal_dismiss %}
|
||||
<div id="htmx-modal" hx-swap-oob="delete"></div>
|
||||
{% endblock %}
|
||||
@@ -53,6 +53,14 @@
|
||||
{% endif %}
|
||||
|
||||
{% set interactive = not is_past and spot.actionable %}
|
||||
{# An administrator may free somebody else's desk -- a colleague calls in sick and the spot
|
||||
should go back into the pool. Colours are untouched: MINE still means mine, and only
|
||||
clickability widens. Past days stay inert for admins too; there is no past-write path.
|
||||
|
||||
Only a shape that is not already actionable: a partly booked one still opens the create
|
||||
modal, because taking the free window is the more useful act there and an admin can reach
|
||||
that booking from /admin/bookings anyway. #}
|
||||
{% set manageable = not is_past and not interactive and is_granted('ROLE_ADMIN') and spot.bookings is not empty %}
|
||||
|
||||
{# One line per booking, then the window still free if there is one -- that last line is the
|
||||
reason a partly booked shape is worth clicking, so it earns its place. #}
|
||||
@@ -77,8 +85,8 @@
|
||||
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
|
||||
{% endif %}
|
||||
|
||||
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive %} role="button"{% endif %}
|
||||
<g class="{{ interactive or manageable ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive or manageable %} role="button"{% endif %}
|
||||
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
|
||||
{%- if interactive and state == 'MINE' %}
|
||||
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
|
||||
@@ -90,6 +98,12 @@
|
||||
hx-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif manageable %}
|
||||
{# Not app_booking_edit: a shape can hold several bookings on one day, and an admin
|
||||
releases rather than reschedules. The modal lists them and frees one. #}
|
||||
hx-get="{{ path('app_admin_spot', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- endif %}>
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
differing from it.
|
||||
|
||||
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
|
||||
or inline CSRF tokens -- clicking your own desk always just opens this.
|
||||
or inline CSRF tokens -- clicking your own desk always just opens this. The release button
|
||||
opens the shared confirmation modal over this one.
|
||||
#}
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
@@ -32,14 +33,13 @@
|
||||
{% include 'booking/_fields.html.twig' %}
|
||||
|
||||
<div class="flex flex-wrap justify-between gap-2 pt-6">
|
||||
{# type="button" with its own hx-post: htmx issues the release request directly, so
|
||||
this never submits the surrounding edit form and no nested <form> is needed. #}
|
||||
{# type="button" with its own hx-get: htmx opens the confirmation modal over this
|
||||
one, so nothing submits the surrounding edit form and no nested <form> is needed.
|
||||
Confirming removes both modals -- see BookingController::boardResponse(). #}
|
||||
<button type="button" class="button button--small button--warning"
|
||||
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
|
||||
hx-confirm="{{ resource.label }} am {{ booking.date|date('d.m.Y') }} freigeben?"
|
||||
hx-target="#htmx-modal"
|
||||
hx-swap="outerHTML">
|
||||
hx-get="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Freigeben
|
||||
</button>
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Serie beenden?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{{ series.resource.label }} · {{ series.describe }}
|
||||
{% if series.user.id != app.user.id %}
|
||||
— Serie von <em>{{ series.user.name }}</em>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="pt-3 text-sm text-gray-500">
|
||||
Alle Termine ab heute werden entfernt. Vergangene Termine bleiben als Historie stehen.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Serie beenden{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'htmx_confirmation_modal.html.twig' %}
|
||||
|
||||
{% block title %}Buchung freigeben?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>
|
||||
{{ booking.resource.type.label }} <em>{{ booking.resource.label }}</em>
|
||||
{%- if booking.resource.group %} ({{ booking.resource.group.label }}){% endif %}
|
||||
am {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}, {{ booking.timeRange }}
|
||||
{% if not booking.belongsTo(app.user) %}
|
||||
— gebucht von <em>{{ booking.user.name }}</em>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if booking.partOfSeries %}
|
||||
<p class="pt-3 text-sm text-gray-500">
|
||||
Teil einer Serie: {{ booking.series.describe }}. Es wird nur dieser Tag freigegeben.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block button_confirm %}Freigeben{% endblock %}
|
||||
@@ -22,12 +22,14 @@
|
||||
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</div>
|
||||
</div>
|
||||
{# Ends the series from today on; past occurrences are history and stay put. #}
|
||||
<form method="post" action="{{ path('app_series_end', { id: entry.id }) }}"
|
||||
onsubmit="return confirm('Serie ab heute beenden? Alle künftigen Termine werden entfernt.')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('end-series-' ~ entry.id) }}">
|
||||
<button type="submit" class="button button--small button--neutral">Serie beenden</button>
|
||||
</form>
|
||||
{# Opens the confirmation modal; the CSRF token and the actual POST live in
|
||||
there, which is why this row carries neither a form nor a token. #}
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_series_end', { id: entry.id, return: 'bookings' }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Serie beenden
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -62,12 +64,14 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<a class="text-sm underline"
|
||||
href="{{ path('app_board', { type: booking.resource.type.value, date: 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>
|
||||
{# ?return=bookings: this page has no board fragments to swap, so the
|
||||
confirmed release navigates back here instead. #}
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'bookings' }) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend">
|
||||
Freigeben
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -47,6 +47,23 @@
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
|
||||
</a>
|
||||
|
||||
{#
|
||||
The header is deliberately not navigation, so the dashboard is the only place an
|
||||
admin screen can be reached from.
|
||||
#}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="{{ path('app_admin_booking') }}"
|
||||
class="group flex flex-col rounded-md border border-gray-200 p-6 hover:border-primary hover:bg-gray-50">
|
||||
<div class="flex items-center gap-3 pb-2">
|
||||
{{ ux_icon('lucide:shield-check', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
|
||||
<span class="text-lg font-bold">Verwaltung</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 pb-4">
|
||||
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
|
||||
</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="pt-10">Als Nächstes</h2>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{#
|
||||
The shared "are you sure?" modal, ported from myep/myep-team's htmx_confirmation_modal.html.twig.
|
||||
|
||||
It replaces both native confirmation mechanisms this app used before: htmx's `hx-confirm`
|
||||
(a window.confirm, unstyleable and invisible to the page) and `onsubmit="return confirm(...)"`
|
||||
on the list forms. Both are gone; neither may come back.
|
||||
|
||||
The flow is the sibling one: a route serves GET (render this) and POST (do the thing), the
|
||||
opener is a plain `hx-get` button appending to <body>, and the confirm button posts back to
|
||||
the *same* URI -- so the query string that brought the user here, `?return=` included, is
|
||||
carried over for free.
|
||||
|
||||
The CSRF token lives here rather than on the opener, which is why the lists no longer carry
|
||||
hidden token fields at all.
|
||||
|
||||
Blocks: title, content, button_attribute (replace the hx-* defaults), button_confirm,
|
||||
button_cancel. Callers must pass `csrf_token_id`.
|
||||
#}
|
||||
<div id="htmx-confirmation-modal"
|
||||
class="fixed inset-0 w-full h-full z-50"
|
||||
{{ stimulus_controller('modal') }}
|
||||
{{ stimulus_action('modal', 'close', 'modal-close@window') }}>
|
||||
<div class="absolute inset-0 w-full h-full bg-black/70" {{ stimulus_action('modal', 'close', 'click') }}></div>
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-xl">
|
||||
<div class="relative bg-white py-8 rounded-md">
|
||||
<div class="flex justify-between pb-4 px-8">
|
||||
<div class="flex items-center gap-2">
|
||||
{{ ux_icon('lucide:triangle-alert', { class: 'w-7 h-7 shrink-0 text-amber-500' }) }}
|
||||
<div class="text-2xl font-bold">
|
||||
{% block title %}Bist du sicher?{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-10 h-10"
|
||||
{{ stimulus_action('modal', 'close') }}>
|
||||
{{ ux_icon('lucide:x', { class: 'w-8 h-8' }) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-8">
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
{% block button_cancel %}Abbrechen{% endblock %}
|
||||
</button>
|
||||
{#
|
||||
Targets itself with outerHTML, so a response consisting only of
|
||||
out-of-band blocks leaves nothing to swap in and this modal disappears --
|
||||
the same mechanism the booking modal uses. A response that instead sets
|
||||
HX-Redirect navigates away and the swap never happens.
|
||||
#}
|
||||
<button type="button"
|
||||
class="button button--warning"
|
||||
hx-target="#htmx-confirmation-modal"
|
||||
hx-swap="outerHTML"
|
||||
{% block button_attribute %}
|
||||
hx-post="{{ app.request.uri }}"
|
||||
hx-vals="{{ { _token: csrf_token(csrf_token_id) }|json_encode|e('html_attr') }}"
|
||||
{% endblock %}>
|
||||
{% block button_confirm %}Ja{% endblock %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Booking;
|
||||
|
||||
use App\Booking\Horizon;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HorizonTest extends TestCase
|
||||
{
|
||||
public function testTheHorizonIsThreeMonthsFromTheGivenDay(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'2026-12-14',
|
||||
Horizon::latest(new \DateTimeImmutable('2026-09-14'))->format('Y-m-d'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP's "+3 months" from a 31st overflows into the next month (31 November does not exist,
|
||||
* so it becomes 1 December). Pinned rather than clamped: this is a cap, and one extra day of
|
||||
* horizon four times a year is not worth the arithmetic it would take to remove. The
|
||||
* recurrence code deliberately avoids months for exactly this reason -- see
|
||||
* RecurrenceRule::intervalDays() -- but a single boundary can live with it.
|
||||
*/
|
||||
public function testAMonthEndOverflowsByADayAndThatIsAccepted(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'2026-12-01',
|
||||
Horizon::latest(new \DateTimeImmutable('2026-08-31'))->format('Y-m-d'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheTimePartIsDroppedSoTheComparisonIsPerDay(): void
|
||||
{
|
||||
self::assertSame(
|
||||
'00:00:00',
|
||||
Horizon::latest(new \DateTimeImmutable('2026-09-14 17:42:13'))->format('H:i:s'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testItDefaultsToToday(): void
|
||||
{
|
||||
self::assertEquals(
|
||||
Horizon::latest(new \DateTimeImmutable('today')),
|
||||
Horizon::latest(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Admin;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The admin area, and the widened ownership checks it leans on.
|
||||
*
|
||||
* The test that matters most here is the negative one: releasing somebody else's booking must
|
||||
* still be refused for an ordinary employee. Everything else on this screen is a list.
|
||||
*/
|
||||
class BookingAdminControllerTest 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 testAnEmployeeCannotReachTheAdminList(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/admin/bookings');
|
||||
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheAdminListShowsOtherPeoplesBookings(): void
|
||||
{
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('Bea Berger', $crawler->filter('body')->text());
|
||||
}
|
||||
|
||||
public function testAnAdminReleasesABookingOnBehalfOfSomebodyElse(): void
|
||||
{
|
||||
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$id = $booking->getId();
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
// The two-step flow a browser walks: the list opens the confirmation modal, the modal
|
||||
// carries the token and posts back to the same URI.
|
||||
$crawler = $this->client->request('GET', '/admin/bookings');
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release?return=admin"]', $id)));
|
||||
|
||||
$uri = \sprintf('/bookings/%d/release?return=admin', $id);
|
||||
$this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
// ?return= means a genuine navigation rather than a board swap: this page has no board
|
||||
// fragments, so HX-Redirect is the honest answer.
|
||||
self::assertSame('/admin/bookings', $this->client->getResponse()->headers->get('HX-Redirect'));
|
||||
$this->em->clear();
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression that matters: widening the check for admins must not have widened it for
|
||||
* everybody.
|
||||
*/
|
||||
public function testAnEmployeeStillCannotReleaseSomebodyElsesBooking(): void
|
||||
{
|
||||
$booking = $this->booking($this->user('[email protected]'), $this->resource('D1'));
|
||||
$id = $booking->getId();
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => 'whatever']);
|
||||
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
// The ownership guard runs before the CSRF one, so asserting on its message is what
|
||||
// distinguishes "refused because it is not yours" from "refused because of the token".
|
||||
self::assertStringContainsString(
|
||||
'released by the person holding it or by an administrator',
|
||||
(string) $this->client->getResponse()->getContent(),
|
||||
);
|
||||
self::assertNotNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins release; they do not reschedule. Moving somebody else's booking is a different act
|
||||
* and was deliberately left alone.
|
||||
*/
|
||||
public function testAnAdminStillCannotEditSomebodyElsesBooking(): void
|
||||
{
|
||||
$booking = $this->booking($this->user('[email protected]'), $this->resource('D1'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$this->client->request('GET', \sprintf('/bookings/%d/edit', $booking->getId()));
|
||||
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testTheListNarrowsToOnePerson(): void
|
||||
{
|
||||
$bea = $this->user('[email protected]', 'Bea Berger');
|
||||
$this->booking($bea, $this->resource('D1'));
|
||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings?user='.$bea->getId());
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$text = $crawler->filter('ul')->text();
|
||||
self::assertStringContainsString('Bea Berger', $text);
|
||||
self::assertStringNotContainsString('Carl Conrad', $text);
|
||||
}
|
||||
|
||||
public function testTheListNarrowsToOneResourceType(): void
|
||||
{
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('P1', ResourceType::PARKING));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request('GET', '/admin/bookings?type=parking');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$text = $crawler->filter('ul')->text();
|
||||
self::assertStringContainsString('Carl Conrad', $text);
|
||||
self::assertStringNotContainsString('Bea Berger', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The way into the admin modal from the board: a desk held by a colleague is inert for an
|
||||
* employee and clickable for an admin.
|
||||
*/
|
||||
public function testAnOccupiedDeskIsClickableForAnAdminOnly(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$date = (new \DateTimeImmutable('today'))->format('Y-m-d');
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||
self::assertCount(0, $crawler->filter('g[hx-get*="/admin/spot/"]'));
|
||||
|
||||
$this->client->loginUser($this->admin());
|
||||
$crawler = $this->client->request('GET', '/book/desk?date='.$date);
|
||||
self::assertCount(1, $crawler->filter(\sprintf('g[hx-get="/admin/spot/%d/%s"]', $desk->getId(), $date)));
|
||||
}
|
||||
|
||||
public function testTheSpotModalListsTheHolderAndReleasesTheBooking(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/admin/spot/%d/%s', $desk->getId(), $booking->getDate()->format('Y-m-d')),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('Bea Berger', $crawler->filter('#htmx-modal')->text());
|
||||
self::assertCount(1, $crawler->filter(\sprintf('button[hx-get="/bookings/%d/release"]', $booking->getId())));
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal is scoped to the shape that was clicked, not to the whole day.
|
||||
*/
|
||||
public function testTheSpotModalShowsOnlyThatResourcesBookings(): void
|
||||
{
|
||||
$desk = $this->resource('D1', ResourceType::DESK, 'desk-01');
|
||||
$this->booking($this->user('[email protected]', 'Bea Berger'), $desk);
|
||||
$this->booking($this->user('[email protected]', 'Carl Conrad'), $this->resource('D2', ResourceType::DESK, 'desk-02'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/admin/spot/%d/%s', $desk->getId(), (new \DateTimeImmutable('today'))->format('Y-m-d')),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
$text = $crawler->filter('#htmx-modal')->text();
|
||||
self::assertStringContainsString('Bea Berger', $text);
|
||||
self::assertStringNotContainsString('Carl Conrad', $text);
|
||||
}
|
||||
|
||||
public function testTheDashboardTileIsAdminOnly(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
|
||||
|
||||
$this->client->loginUser($this->admin());
|
||||
self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/bookings"]'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The confirmation modal names the holder, so an administrator cannot free the wrong desk
|
||||
* without seeing whose it is. This is the whole reason it exists rather than a
|
||||
* window.confirm.
|
||||
*/
|
||||
public function testTheConfirmationModalNamesTheHolder(): void
|
||||
{
|
||||
$booking = $this->booking($this->user('[email protected]', 'Bea Berger'), $this->resource('D1'));
|
||||
$this->client->loginUser($this->admin());
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/bookings/%d/release?return=admin', $booking->getId()),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$modal = $crawler->filter('#htmx-confirmation-modal');
|
||||
self::assertStringContainsString('Bea Berger', $modal->text());
|
||||
// Posts back to the same URI, which is what carries ?return=admin through the round trip.
|
||||
self::assertSame(
|
||||
\sprintf('http://localhost/bookings/%d/release?return=admin', $booking->getId()),
|
||||
$modal->filter('[hx-post]')->attr('hx-post'),
|
||||
);
|
||||
}
|
||||
|
||||
private function confirmationToken(string $uri): string
|
||||
{
|
||||
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
|
||||
|
||||
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
}
|
||||
|
||||
private function booking(User $user, Resource $resource): Booking
|
||||
{
|
||||
$booking = new Booking($user, $resource, new \DateTimeImmutable('today'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
return $booking;
|
||||
}
|
||||
|
||||
private function admin(string $email = '[email protected]', string $name = 'Chef Chefin'): User
|
||||
{
|
||||
return $this->user($email, $name, ['ROLE_ADMIN']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function user(string $email, string $name = 'Test', array $roles = ['ROLE_EMPLOYEE']): User
|
||||
{
|
||||
$user = new User($email, $name);
|
||||
$user->setRoles($roles);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor plan only draws resources whose svgElementId appears in the geometry map.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Booking\Horizon;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
@@ -184,16 +185,20 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
|
||||
// opens that modal, and the token is rendered there.
|
||||
// The whole two-step flow: the edit modal opens the confirmation modal, and the
|
||||
// confirmation modal is where the token and the POST live.
|
||||
$crawler = $this->client->request('GET', \sprintf('/bookings/%d/edit', $id), server: ['HTTP_HX-Request' => 'true']);
|
||||
$vals = $crawler->filter(\sprintf('[hx-post="/bookings/%d/release"]', $id))->attr('hx-vals');
|
||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release"]', $id)));
|
||||
|
||||
$token = $this->confirmationToken(\sprintf('/bookings/%d/release', $id));
|
||||
|
||||
$this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
$content = (string) $this->client->getResponse()->getContent();
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame(3, substr_count((string) $this->client->getResponse()->getContent(), 'hx-swap-oob="true"'));
|
||||
self::assertSame(3, substr_count($content, 'hx-swap-oob="true"'));
|
||||
// The confirmation swaps itself away; the edit modal underneath it has to go too.
|
||||
self::assertStringContainsString('id="htmx-modal" hx-swap-oob="delete"', $content);
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
@@ -345,14 +350,23 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
||||
// Relative to today, unlike the rest of this file: the three-month horizon moves with the
|
||||
// clock, so a fixed end date here would start failing the day it fell outside it.
|
||||
$start = (new \DateTimeImmutable('today'))->modify('next monday');
|
||||
$until = $start->modify('+56 days');
|
||||
|
||||
$response = $this->submitBookingModal($desk, $start->format('Y-m-d'), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'four_weekly',
|
||||
'until' => '2026-12-31',
|
||||
'until' => $until->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertCount(4, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
|
||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]);
|
||||
self::assertCount(3, $occurrences);
|
||||
foreach ($occurrences as $booking) {
|
||||
self::assertSame(1, (int) $booking->getDate()->format('N'));
|
||||
}
|
||||
}
|
||||
|
||||
public function testASeriesWithoutAnEndDateIsRejected(): void
|
||||
@@ -379,7 +393,86 @@ class BookingControllerTest extends WebTestCase
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent());
|
||||
self::assertStringContainsString('höchstens 3 Monate', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testABookingBeyondTheHorizonIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$beyond = Horizon::latest()->modify('+1 day');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 3 Monate im Voraus', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testTheLastDayInsideTheHorizonIsStillBookable(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), Horizon::latest()->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The hole in the old rule, which measured the cap from the series' own start date: starting
|
||||
* later simply pushed the horizon out with it. This is the property that would regress
|
||||
* silently if the measurement ever moved back onto $this->date.
|
||||
*/
|
||||
public function testASeriesCannotPushTheHorizonOutByStartingLater(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$start = (new \DateTimeImmutable('today'))->modify('+2 months');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $start->format('Y-m-d'), [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'weekly',
|
||||
'until' => $start->modify('+2 months')->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 3 Monate in die Zukunft', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAnAdminIsNotBoundByTheHorizon(): void
|
||||
{
|
||||
$this->client->loginUser($this->admin('[email protected]'));
|
||||
$beyond = Horizon::latest()->modify('+2 months');
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), $beyond->format('Y-m-d'), ['allDay' => '1']);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Past days stay clickable -- looking back at a plan is legitimate -- but they have to say
|
||||
* that nothing can be booked there, which they did not before.
|
||||
*/
|
||||
public function testPastDaysInTheCalendarAreMarkedButStillNavigable(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$yesterday = (new \DateTimeImmutable('today'))->modify('-1 day')->format('Y-m-d');
|
||||
|
||||
$crawler = $this->client->request('GET', '/book/desk');
|
||||
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $yesterday));
|
||||
|
||||
self::assertCount(1, $cell);
|
||||
self::assertSame('true', $cell->attr('aria-disabled'));
|
||||
self::assertStringContainsString('border-dashed', (string) $cell->attr('class'));
|
||||
}
|
||||
|
||||
public function testFutureDaysInTheCalendarAreNotMarkedAsPast(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$tomorrow = (new \DateTimeImmutable('today'))->modify('+1 day')->format('Y-m-d');
|
||||
|
||||
$crawler = $this->client->request('GET', '/book/desk');
|
||||
$cell = $crawler->filter(\sprintf('button[hx-get*="/book/desk/day/%s"]', $tomorrow));
|
||||
|
||||
self::assertCount(1, $cell);
|
||||
self::assertNull($cell->attr('aria-disabled'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,6 +572,30 @@ class BookingControllerTest extends WebTestCase
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a confirmation modal and reads its CSRF token off the confirm button's hx-vals --
|
||||
* the same round trip a browser makes, rather than reaching into the token manager (which
|
||||
* has no session to read outside a request).
|
||||
*/
|
||||
private function confirmationToken(string $uri): string
|
||||
{
|
||||
$crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals');
|
||||
|
||||
return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
}
|
||||
|
||||
private function admin(string $email, string $name = 'Chef'): User
|
||||
{
|
||||
$user = $this->user($email, $name);
|
||||
$user->setRoles(['ROLE_ADMIN']);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function user(string $email, string $name = 'Test'): User
|
||||
{
|
||||
$user = new User($email, $name);
|
||||
|
||||
Reference in new issue
Block a user