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: ^/(login|auth|logout)$, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/auth/, roles: PUBLIC_ACCESS }
|
- { path: ^/auth/, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/dev/, 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
|
# 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
|
# 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
|
# 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\BoardPresenter;
|
||||||
use App\Booking\BookingManager;
|
use App\Booking\BookingManager;
|
||||||
use App\Booking\DateParam;
|
use App\Booking\DateParam;
|
||||||
|
use App\Booking\Horizon;
|
||||||
use App\Booking\ResourceAlreadyBookedException;
|
use App\Booking\ResourceAlreadyBookedException;
|
||||||
use App\Booking\Spot;
|
use App\Booking\Spot;
|
||||||
use App\Booking\SeriesResult;
|
use App\Booking\SeriesResult;
|
||||||
@@ -21,6 +22,8 @@ use App\Form\BookingType;
|
|||||||
use App\Htmx\HxTrait;
|
use App\Htmx\HxTrait;
|
||||||
use App\Repository\BookingRepository;
|
use App\Repository\BookingRepository;
|
||||||
use App\Repository\BookingSeriesRepository;
|
use App\Repository\BookingSeriesRepository;
|
||||||
|
use App\Security\Role;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\Form\FormError;
|
use Symfony\Component\Form\FormError;
|
||||||
use Symfony\Component\Form\FormInterface;
|
use Symfony\Component\Form\FormInterface;
|
||||||
@@ -38,6 +41,7 @@ final class BookingController extends AbstractController
|
|||||||
private readonly BookingManager $bookings,
|
private readonly BookingManager $bookings,
|
||||||
private readonly BookingRepository $repository,
|
private readonly BookingRepository $repository,
|
||||||
private readonly BookingSeriesRepository $series,
|
private readonly BookingSeriesRepository $series,
|
||||||
|
private readonly LoggerInterface $logger,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +86,13 @@ final class BookingController extends AbstractController
|
|||||||
$this->currentUser(),
|
$this->currentUser(),
|
||||||
);
|
);
|
||||||
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
|
$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, [
|
$form = $this->createForm(BookingType::class, $data, [
|
||||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
||||||
|
'horizon' => $data->unlimitedHorizon ? null : Horizon::latest(),
|
||||||
]);
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
@@ -122,6 +130,8 @@ final class BookingController extends AbstractController
|
|||||||
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
|
#[Route('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
|
||||||
public function edit(Request $request, Booking $booking): Response
|
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);
|
$this->denyUnlessOwn($booking);
|
||||||
|
|
||||||
$boardTime = BoardController::timeOf($request);
|
$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
|
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());
|
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
||||||
|
|
||||||
$date = $booking->getDate();
|
$date = $booking->getDate();
|
||||||
$type = $booking->getResource()->getType();
|
$type = $booking->getResource()->getType();
|
||||||
$label = $booking->getResource()->getLabel();
|
$label = $booking->getResource()->getLabel();
|
||||||
$this->bookings->release($booking);
|
$holder = $booking->getUser();
|
||||||
|
$onBehalf = !$booking->belongsTo($this->currentUser());
|
||||||
|
|
||||||
$this->addFlash('success', \sprintf('%s freigegeben.', $label));
|
if ($onBehalf) {
|
||||||
|
// The row is about to be deleted, so without this line nothing records that an
|
||||||
// The bookings list posts this as an ordinary form and has no board fragments to swap.
|
// administrator freed somebody else's desk.
|
||||||
if (!$request->headers->has('HX-Request')) {
|
$this->logger->info('Admin released a booking on behalf of another user.', [
|
||||||
return $this->redirectToRoute('app_booking');
|
'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.
|
* 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
|
public function endSeries(Request $request, BookingSeries $series): Response
|
||||||
{
|
{
|
||||||
if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
|
$this->denyUnlessMayManageSeries($series);
|
||||||
throw $this->createAccessDeniedException('A series may only be ended by the person who created it.');
|
|
||||||
|
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());
|
$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'));
|
$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
|
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"
|
* 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).
|
* (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(
|
return $this->htmxOobResponse(
|
||||||
'board/index.html.twig',
|
'board/index.html.twig',
|
||||||
['calendar', 'floorplan', 'flashes'],
|
$blocks,
|
||||||
$this->board->context($type, $date, $time, $this->currentUser()),
|
$this->board->context($type, $date, $time, $this->currentUser()),
|
||||||
$this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
|
$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
|
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
|
||||||
{
|
{
|
||||||
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
|
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ use Doctrine\ORM\Mapping as ORM;
|
|||||||
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
|
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
|
||||||
class BookingSeries
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[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;
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Booking\Horizon;
|
||||||
use App\Booking\RecurrenceRule;
|
use App\Booking\RecurrenceRule;
|
||||||
use App\Booking\TimeRange;
|
use App\Booking\TimeRange;
|
||||||
use App\Entity\BookingSeries;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
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.')]
|
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
|
||||||
public ?\DateTimeImmutable $date = null;
|
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 bool $allDay = true;
|
||||||
|
|
||||||
public ?\DateTimeImmutable $startTime = null;
|
public ?\DateTimeImmutable $startTime = null;
|
||||||
@@ -85,6 +92,8 @@ final class BookingRequest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->validateHorizon($context);
|
||||||
|
|
||||||
if (!$this->repeats()) {
|
if (!$this->repeats()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,14 +110,31 @@ final class BookingRequest
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$latest = ($this->date ?? new \DateTimeImmutable('today'))->modify(\sprintf('+%d months', BookingSeries::MAX_MONTHS));
|
if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) {
|
||||||
if ($this->until > $latest) {
|
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS))
|
||||||
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate laufen.', BookingSeries::MAX_MONTHS))
|
|
||||||
->atPath('until')
|
->atPath('until')
|
||||||
->addViolation();
|
->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
|
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.
|
// 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
|
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
|
$builder
|
||||||
->add('date', DateType::class, [
|
->add('date', DateType::class, [
|
||||||
'label' => 'Tag',
|
'label' => 'Tag',
|
||||||
'widget' => 'single_text',
|
'widget' => 'single_text',
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
|
'attr' => $horizon,
|
||||||
])
|
])
|
||||||
->add('allDay', CheckboxType::class, [
|
->add('allDay', CheckboxType::class, [
|
||||||
'label' => 'Ganzer Tag',
|
'label' => 'Ganzer Tag',
|
||||||
@@ -53,6 +61,7 @@ final class BookingType extends AbstractType
|
|||||||
'widget' => 'single_text',
|
'widget' => 'single_text',
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
'attr' => $horizon,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +69,8 @@ final class BookingType extends AbstractType
|
|||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => BookingRequest::class,
|
'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\Resource;
|
||||||
use App\Entity\ResourceType;
|
use App\Entity\ResourceType;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
|
use App\Form\Admin\BookingFilter;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -169,6 +170,41 @@ class BookingRepository extends ServiceEntityRepository
|
|||||||
return $counts;
|
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.
|
* The occurrences an "end this series" would remove.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Repository;
|
namespace App\Repository;
|
||||||
|
|
||||||
use App\Entity\BookingSeries;
|
use App\Entity\BookingSeries;
|
||||||
|
use App\Entity\ResourceType;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
use Doctrine\Persistence\ManagerRegistry;
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
@@ -24,15 +25,38 @@ class BookingSeriesRepository extends ServiceEntityRepository
|
|||||||
*/
|
*/
|
||||||
public function findActiveForUser(User $user, \DateTimeImmutable $on): array
|
public function findActiveForUser(User $user, \DateTimeImmutable $on): array
|
||||||
{
|
{
|
||||||
return $this->createQueryBuilder('s')
|
return $this->findActive($on, $user);
|
||||||
->addSelect('r')
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
->join('s.resource', 'r')
|
||||||
->andWhere('s.user = :user')
|
->leftJoin('r.group', 'g')
|
||||||
->andWhere('s.endDate >= :on')
|
->andWhere('s.endDate >= :on')
|
||||||
->setParameter('user', $user)
|
|
||||||
->setParameter('on', $on->setTime(0, 0))
|
->setParameter('on', $on->setTime(0, 0))
|
||||||
->orderBy('s.startDate', 'ASC')
|
->orderBy('s.startDate', 'ASC')
|
||||||
->getQuery()
|
->addOrderBy('u.name', 'ASC');
|
||||||
->getResult();
|
|
||||||
|
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' %}
|
{% set classes = classes ~ ' border-primary bg-primary text-white font-bold' %}
|
||||||
{% elseif not day.in_month %}
|
{% elseif not day.in_month %}
|
||||||
{% set classes = classes ~ ' border-transparent text-gray-300' %}
|
{% 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 %}
|
{% elseif day.is_weekend %}
|
||||||
{% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %}
|
{% set classes = classes ~ ' border-transparent bg-gray-50 text-gray-400' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -49,15 +54,16 @@
|
|||||||
<button type="button"
|
<button type="button"
|
||||||
class="{{ classes }}"
|
class="{{ classes }}"
|
||||||
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
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-get="{{ path('app_board_day', { type: type.value, date: day.date|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-swap="none">
|
hx-swap="none">
|
||||||
<span>{{ day.date|date('j') }}</span>
|
<span>{{ day.date|date('j') }}</span>
|
||||||
{# Team view: the occupancy count is the whole point of showing the month. #}
|
{# 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 : '' }}
|
{{ day.count > 0 ? day.count : '' }}
|
||||||
</span>
|
</span>
|
||||||
{% if day.mine is not empty %}
|
{% 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 %}
|
{% endif %}
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -68,6 +74,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
|
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
|
||||||
</div>
|
</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>Zahl = belegte Plätze{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,3 +32,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% set interactive = not is_past and spot.actionable %}
|
{% 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
|
{# 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. #}
|
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']) %}
|
{% set lines = lines|slice(0, MAX_LINES - 1)|merge(['+' ~ (lines|length - MAX_LINES + 1) ~ ' weitere']) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
<g class="{{ interactive or manageable ? 'cursor-pointer' : 'cursor-default' }}"
|
||||||
{%- if interactive %} role="button"{% endif %}
|
{%- if interactive or manageable %} role="button"{% endif %}
|
||||||
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
|
aria-label="{{ spot.resource.label }}{% if lines is empty %} — frei{% else %} — {{ lines|join(', ') }}{% endif %}"
|
||||||
{%- if interactive and state == 'MINE' %}
|
{%- if interactive and state == 'MINE' %}
|
||||||
hx-get="{{ path('app_booking_edit', { id: spot.own.id }|merge(time_params)) }}"
|
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-get="{{ path('app_booking_new', { id: spot.resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||||
hx-target="body"
|
hx-target="body"
|
||||||
hx-swap="beforeend"
|
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 %}>
|
{%- endif %}>
|
||||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
differing from it.
|
differing from it.
|
||||||
|
|
||||||
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
|
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' %}
|
{% extends 'htmx_modal.html.twig' %}
|
||||||
|
|
||||||
@@ -32,14 +33,13 @@
|
|||||||
{% include 'booking/_fields.html.twig' %}
|
{% include 'booking/_fields.html.twig' %}
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-between gap-2 pt-6">
|
<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
|
{# type="button" with its own hx-get: htmx opens the confirmation modal over this
|
||||||
this never submits the surrounding edit form and no nested <form> is needed. #}
|
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"
|
<button type="button" class="button button--small button--warning"
|
||||||
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
|
hx-get="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||||
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
|
hx-target="body"
|
||||||
hx-confirm="{{ resource.label }} am {{ booking.date|date('d.m.Y') }} freigeben?"
|
hx-swap="beforeend">
|
||||||
hx-target="#htmx-modal"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
Freigeben
|
Freigeben
|
||||||
</button>
|
</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') }}
|
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Ends the series from today on; past occurrences are history and stay put. #}
|
{# Opens the confirmation modal; the CSRF token and the actual POST live in
|
||||||
<form method="post" action="{{ path('app_series_end', { id: entry.id }) }}"
|
there, which is why this row carries neither a form nor a token. #}
|
||||||
onsubmit="return confirm('Serie ab heute beenden? Alle künftigen Termine werden entfernt.')">
|
<button type="button" class="button button--small button--neutral"
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('end-series-' ~ entry.id) }}">
|
hx-get="{{ path('app_series_end', { id: entry.id, return: 'bookings' }) }}"
|
||||||
<button type="submit" class="button button--small button--neutral">Serie beenden</button>
|
hx-target="body"
|
||||||
</form>
|
hx-swap="beforeend">
|
||||||
|
Serie beenden
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -62,12 +64,14 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a class="text-sm underline"
|
<a class="text-sm underline"
|
||||||
href="{{ path('app_board', { type: booking.resource.type.value, date: booking.date|date('Y-m-d') }) }}">Im Plan</a>
|
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
|
{# ?return=bookings: this page has no board fragments to swap, so the
|
||||||
reloads afterwards rather than pretending to be htmx. #}
|
confirmed release navigates back here instead. #}
|
||||||
<form method="post" action="{{ path('app_booking_release', { id: booking.id }) }}">
|
<button type="button" class="button button--small button--neutral"
|
||||||
<input type="hidden" name="_token" value="{{ csrf_token('release-' ~ booking.id) }}">
|
hx-get="{{ path('app_booking_release', { id: booking.id, return: 'bookings' }) }}"
|
||||||
<button type="submit" class="button button--small button--neutral">Freigeben</button>
|
hx-target="body"
|
||||||
</form>
|
hx-swap="beforeend">
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -47,6 +47,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
|
<p class="text-sm text-gray-500 pb-4">Alle Termine und Serien verwalten.</p>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h2 class="pt-10">Als Nächstes</h2>
|
<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;
|
namespace App\Tests\Controller;
|
||||||
|
|
||||||
|
use App\Booking\Horizon;
|
||||||
use App\Booking\TimeRange;
|
use App\Booking\TimeRange;
|
||||||
use App\Entity\Booking;
|
use App\Entity\Booking;
|
||||||
use App\Entity\Resource;
|
use App\Entity\Resource;
|
||||||
@@ -184,16 +185,20 @@ class BookingControllerTest extends WebTestCase
|
|||||||
|
|
||||||
$this->client->loginUser($anna);
|
$this->client->loginUser($anna);
|
||||||
|
|
||||||
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
|
// The whole two-step flow: the edit modal opens the confirmation modal, and the
|
||||||
// opens that modal, and the token is rendered there.
|
// 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']);
|
$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');
|
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/bookings/%d/release"]', $id)));
|
||||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
|
||||||
|
$token = $this->confirmationToken(\sprintf('/bookings/%d/release', $id));
|
||||||
|
|
||||||
$this->client->request('POST', \sprintf('/bookings/%d/release', $id), ['_token' => $token], server: ['HTTP_HX-Request' => 'true']);
|
$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::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));
|
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,14 +350,23 @@ class BookingControllerTest extends WebTestCase
|
|||||||
$this->client->loginUser($this->user('[email protected]'));
|
$this->client->loginUser($this->user('[email protected]'));
|
||||||
$desk = $this->resource('D1');
|
$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',
|
'allDay' => '1',
|
||||||
'repeat' => 'four_weekly',
|
'repeat' => 'four_weekly',
|
||||||
'until' => '2026-12-31',
|
'until' => $until->format('Y-m-d'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
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
|
public function testASeriesWithoutAnEndDateIsRejected(): void
|
||||||
@@ -379,7 +393,86 @@ class BookingControllerTest extends WebTestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
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();
|
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
|
private function user(string $email, string $name = 'Test'): User
|
||||||
{
|
{
|
||||||
$user = new User($email, $name);
|
$user = new User($email, $name);
|
||||||
|
|||||||
Reference in new issue
Block a user