feat: add booking time ranges and repeating series
This commit is contained in:
1 parent
59feca2f8e
commit
d433d271d0
29 files changed
+2135
-180
No files matched your search
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user