feat: add admin views, a booking horizon and confirmation modals

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
frommeandClaude Opus 5 committed 2026-09-14 09:40:17 +02:00
1 parent 8caf618950
commit 1a3640b15c
27 files changed
+1269 -62

No files matched your search

+1
View File
@@ -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

+1
View File
@@ -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

+3
View File
@@ -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
+31
View File
@@ -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(),
)),
]);
}
}
+117 -16
View File
@@ -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'))) {
-3
View File
@@ -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]
+46
View File
@@ -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);
}
}
+74
View File
@@ -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 '';
}
}
+30 -4
View File
@@ -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.
+11
View File
@@ -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]);
} }
} }
+36
View File
@@ -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.
* *
+30 -6
View File
@@ -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();
} }
} }
+52
View File
@@ -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 %}
+104
View File
@@ -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 %}
+9 -2
View File
@@ -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>
+11
View File
@@ -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 %}
+16 -2
View File
@@ -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>
+8 -8
View File
@@ -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 %}
+16 -12
View File
@@ -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 %}
+17
View File
@@ -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>
+50
View File
@@ -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;
}
}
+126 -9
View File
@@ -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);