feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+162 -30
View File
@@ -9,14 +9,19 @@ use App\Booking\BoardPresenter;
use App\Booking\BookingManager;
use App\Booking\DateParam;
use App\Booking\ResourceAlreadyBookedException;
use App\Booking\SeriesResult;
use App\Booking\TimeRange;
use App\Entity\Booking;
use App\Entity\BookingSeries;
use App\Entity\Resource;
use App\Form\BookingRequest;
use App\Form\BookingType;
use App\Htmx\HxTrait;
use App\Repository\BookingRepository;
use App\Repository\BookingSeriesRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -30,6 +35,7 @@ final class BookingController extends AbstractController
private readonly BoardPresenter $board,
private readonly BookingManager $bookings,
private readonly BookingRepository $repository,
private readonly BookingSeriesRepository $series,
) {
}
@@ -40,6 +46,7 @@ final class BookingController extends AbstractController
return $this->render('booking/index.html.twig', [
'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
]);
}
@@ -48,7 +55,7 @@ final class BookingController extends AbstractController
*
* On an invalid submit this just render()s the modal again -- Symfony turns that into a 422
* and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces
* itself with the errors showing. On success the response carries nothing but the two
* itself with the errors showing. On success the response carries nothing but the
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
* fragment and the modal simply disappears.
*/
@@ -60,34 +67,36 @@ final class BookingController extends AbstractController
}
$selected = DateParam::parseOrToday($date);
$boardTime = DashboardController::timeOf($request);
$form = $this->createForm(BookingType::class, new BookingRequest($selected), [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date]),
// Prefilled from whatever span the board is showing, so narrowing the board and then
// clicking a desk books exactly what was on screen.
$data = new BookingRequest($selected, $boardTime);
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$requested = $form->getData();
\assert($requested instanceof BookingRequest);
\assert($requested->date instanceof \DateTimeImmutable);
\assert($data->date instanceof \DateTimeImmutable);
try {
$this->bookings->book($this->currentUser(), $resource, $requested->date);
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
// Deliberately renders from data already in hand: the losing side of a race has
// a CLOSED entity manager at this point (see BookingManager::book), so this
// branch must not query.
$form->addError(new FormError($e->getMessage()));
$result = $data->repeats()
? $this->createSeries($data, $resource)
: null;
return $this->render('booking/_modal_create.html.twig', [
'form' => $form,
'resource' => $resource,
]);
if (null === $result) {
$this->bookings->book($this->currentUser(), $resource, $data->date, $data->timeRange());
$this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange()));
} else {
$this->flashSeriesResult($result, $resource);
}
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
return $this->invalidModal($form, $resource, $e->getMessage());
}
$this->addFlash('success', \sprintf('%s gebucht.', $resource->getLabel()));
return $this->boardResponse($requested->date);
return $this->boardResponse($data->date, $boardTime);
}
return $this->render('booking/_modal_create.html.twig', [
@@ -96,16 +105,50 @@ final class BookingController extends AbstractController
]);
}
/**
* Moves one occurrence, which is what an "override" of a series is: the other occurrences
* are untouched and this one is flagged as differing from its series.
*/
#[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Booking $booking): Response
{
$this->denyUnlessOwn($booking);
$boardTime = DashboardController::timeOf($request);
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
$form = $this->createForm(BookingType::class, $data, [
'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)),
]);
// The day and any recurrence belong to the original booking; only the span is editable
// here, so a series is never silently rewritten by editing one of its occurrences.
$form->remove('date')->remove('repeat')->remove('weekdays')->remove('until');
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
try {
$this->bookings->reschedule($booking, $data->timeRange());
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
return $this->invalidModal($form, $booking->getResource(), $e->getMessage(), $booking);
}
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
return $this->boardResponse($booking->getDate(), $boardTime);
}
return $this->render('booking/_modal_edit.html.twig', [
'form' => $form,
'resource' => $booking->getResource(),
'booking' => $booking,
]);
}
#[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])]
public function release(Request $request, Booking $booking): Response
{
if (!$booking->belongsTo($this->currentUser())) {
throw $this->createAccessDeniedException('Fremde Buchungen können nicht freigegeben werden.');
}
if (!$this->isCsrfTokenValid('release-'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
}
$this->denyUnlessOwn($booking);
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
$date = $booking->getDate();
$label = $booking->getResource()->getLabel();
@@ -118,20 +161,109 @@ final class BookingController extends AbstractController
return $this->redirectToRoute('app_booking');
}
return $this->boardResponse($date);
return $this->boardResponse($date, DashboardController::timeOf($request));
}
/**
* Ends a series from today onwards. Past occurrences are history and stay put.
*/
#[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])]
public function endSeries(Request $request, BookingSeries $series): Response
{
if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.');
}
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
$removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today'));
$this->addFlash('success', \sprintf('Serie beendet, %d Termine entfernt.', $removed));
return $this->redirectToRoute('app_booking');
}
private function createSeries(BookingRequest $data, Resource $resource): SeriesResult
{
\assert($data->date instanceof \DateTimeImmutable);
\assert($data->repeat instanceof \App\Booking\RecurrenceRule);
\assert($data->until instanceof \DateTimeImmutable);
return $this->bookings->bookSeries(new BookingSeries(
$this->currentUser(),
$resource,
$data->repeat,
$data->date,
$data->until,
$data->timeRange(),
$data->weekdays,
));
}
private function flashSeriesResult(SeriesResult $result, Resource $resource): void
{
if ($result->isCompletelyBlocked()) {
$this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel()));
return;
}
$message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount());
if ($result->skippedCount() > 0) {
$message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates()));
}
$this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message);
}
/**
* Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by
* itself, which is exactly what htmx needs to swap it.
*/
private function invalidModal(FormInterface $form, Resource $resource, string $message, ?Booking $booking = null): Response
{
$form->addError(new FormError($message));
return $this->render(
null === $booking ? 'booking/_modal_create.html.twig' : 'booking/_modal_edit.html.twig',
['form' => $form, 'resource' => $resource, 'booking' => $booking],
);
}
/**
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
* (or an outerHTML target that should vanish, as the modal does).
*/
private function boardResponse(\DateTimeImmutable $date): Response
private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response
{
return $this->htmxOobResponse(
'dashboard/index.html.twig',
['calendar', 'floorplan', 'flashes'],
$this->board->context($date, $this->currentUser()),
$this->generateUrl('app_dashboard', ['tag' => $date->format('Y-m-d')]),
$this->board->context($date, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)),
);
}
/**
* @return array<string, string>
*/
private function timeParameters(TimeRange $time): array
{
return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()];
}
private function denyUnlessOwn(Booking $booking): void
{
if (!$booking->belongsTo($this->currentUser())) {
throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.');
}
}
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
{
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
}
}
}
+19 -6
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Booking\BoardPresenter;
use App\Booking\DateParam;
use App\Booking\TimeRange;
use App\Htmx\HxTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -13,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* The booking board: a month calendar next to the floor plan for the selected day.
* The booking board: a month calendar next to the floor plan for the selected day and span.
*/
final class DashboardController extends AbstractController
{
@@ -29,11 +30,12 @@ final class DashboardController extends AbstractController
{
$date = DateParam::parseOrToday($request->query->getString('tag'));
return $this->render('dashboard/index.html.twig', $this->board->context($date, $this->currentUser()));
return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser()));
}
/**
* Picking a day swaps the calendar and the floor plan in one response.
* Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one
* response.
*
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
* client-side event choreography. Requests without the htmx header get a plain redirect
@@ -43,16 +45,27 @@ final class DashboardController extends AbstractController
public function day(Request $request, string $date): Response
{
$selected = DateParam::parseOrToday($date);
$time = self::timeOf($request);
if (!$request->headers->has('HX-Request')) {
return $this->redirectToRoute('app_dashboard', ['tag' => $selected->format('Y-m-d')]);
return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time));
}
return $this->htmxOobResponse(
'dashboard/index.html.twig',
['calendar', 'floorplan'],
$this->board->context($selected, $this->currentUser()),
$this->generateUrl('app_dashboard', ['tag' => $selected->format('Y-m-d')]),
$this->board->context($selected, $time, $this->currentUser()),
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)),
);
}
/**
* A malformed or missing span falls back to the whole day rather than erroring: these are
* hand-editable query parameters, not route requirements.
*/
public static function timeOf(Request $request): TimeRange
{
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis'))
?? TimeRange::wholeDay();
}
}