chore: code cleanup
This commit is contained in:
1 parent
1a3640b15c
commit
5a01054b0e
65 files changed
+1046
-795
No files matched your search
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\ResourceRepository;
|
||||
|
||||
/**
|
||||
* The dashboard's tiles: one per bookable kind, built by walking ResourceType.
|
||||
*
|
||||
* Nothing here names a kind. Adding a case to the enum adds a tile with a working link and a
|
||||
* correct count, which is what "extensible" has to mean if it is to survive contact with the
|
||||
* next requirement.
|
||||
*/
|
||||
final readonly class DashboardPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private BookingRepository $bookings,
|
||||
private ResourceRepository $resources,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function tiles(User $user, \DateTimeImmutable $on): array
|
||||
{
|
||||
$on = $on->setTime(0, 0);
|
||||
$tiles = [];
|
||||
|
||||
foreach (ResourceType::cases() as $type) {
|
||||
$resources = $this->resources->findActiveByType($type);
|
||||
|
||||
// Availability is judged over the whole day: a resource booked for any part of it is
|
||||
// not "free today" without qualification, and the dashboard is not the place to ask
|
||||
// the user for a time span before it can say anything.
|
||||
$taken = [];
|
||||
$mine = null;
|
||||
foreach ($this->bookings->findForDate($on, null, $type) as $booking) {
|
||||
$taken[$booking->getResource()->getId()] = true;
|
||||
|
||||
if ($booking->belongsTo($user)) {
|
||||
$mine = $booking;
|
||||
}
|
||||
}
|
||||
|
||||
$tiles[] = [
|
||||
'type' => $type,
|
||||
'total' => \count($resources),
|
||||
'free' => \count($resources) - \count($taken),
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<\App\Entity\Booking>
|
||||
*/
|
||||
public function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array
|
||||
{
|
||||
$bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year'));
|
||||
|
||||
return \array_slice($bookings, 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ namespace App\Command;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Repository\ResourceGroupRepository;
|
||||
use App\Repository\ResourceRepository;
|
||||
use App\Repository\UserRepository;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Config;
|
||||
|
||||
/**
|
||||
* How far ahead a booking may be made.
|
||||
@@ -15,7 +15,7 @@ namespace App\Booking;
|
||||
*
|
||||
* ROLE_ADMIN is exempt -- see BookingRequest::$unlimitedHorizon.
|
||||
*/
|
||||
final class Horizon
|
||||
final class BookingHorizon
|
||||
{
|
||||
public const MONTHS = 3;
|
||||
|
||||
+3
-36
@@ -2,12 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Admin\Booking;
|
||||
|
||||
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;
|
||||
@@ -23,7 +19,7 @@ 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
|
||||
* The admin controllers are read-only. The two actions offered here 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.
|
||||
@@ -32,10 +28,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
* survives a change to the route path.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class BookingAdminController extends AbstractController
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly BookingSeriesRepository $series,
|
||||
@@ -58,31 +52,4 @@ final class BookingAdminController extends AbstractController
|
||||
'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(),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\Booking;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Entity\Resource;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Routing\DateParam;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
#[IsGranted(Role::ADMIN)]
|
||||
final class SpotController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
|
||||
public function __construct(private readonly BookingRepository $bookings)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/spot/{id}/{date}', name: 'app_admin_spot', requirements: ['date' => DateParam::PATTERN], methods: ['GET'])]
|
||||
public function index(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, $this->boardTime($request), $resource->getType()),
|
||||
static fn ($booking): bool => $booking->getResource()->getId() === $resource->getId(),
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Board;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Routing\DateParam;
|
||||
use App\Service\BoardContextFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* 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 rather than a bare
|
||||
* pair of fragments, so the URL stays shareable.
|
||||
*/
|
||||
final class DayController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(private readonly BoardContextFactory $boardContext)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])]
|
||||
public function index(Request $request, string $type, string $date): Response
|
||||
{
|
||||
$resourceType = $this->resourceType($type);
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$time = $this->boardTime($request);
|
||||
|
||||
if (!$request->headers->has('HX-Request')) {
|
||||
return $this->redirectToRoute('app_board', $this->boardUrlParameters($resourceType, $selected, $time));
|
||||
}
|
||||
|
||||
return $this->boardResponse($resourceType, $selected, $time, ['calendar', 'floorplan']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved
|
||||
* here -- an unknown kind is a 404 rather than a 500.
|
||||
*/
|
||||
private function resourceType(string $value): ResourceType
|
||||
{
|
||||
return ResourceType::tryFrom($value)
|
||||
?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Board;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Routing\DateParam;
|
||||
use App\Service\BoardContextFactory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* One booking board per resource kind: a month calendar next to that kind's floor plan.
|
||||
*
|
||||
* The kind is a route parameter rather than a separate controller per kind, so a new
|
||||
* ResourceType case gets a working board without touching this file.
|
||||
*/
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(private readonly BoardContextFactory $boardContext)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])]
|
||||
public function index(Request $request, string $type): Response
|
||||
{
|
||||
return $this->render('board/index.html.twig', $this->boardContext->create(
|
||||
$this->resourceType($type),
|
||||
DateParam::parseOrToday($request->query->getString('date')),
|
||||
$this->boardTime($request),
|
||||
$this->currentUser(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved
|
||||
* here -- an unknown kind is a 404 rather than a 500.
|
||||
*/
|
||||
private function resourceType(string $value): ResourceType
|
||||
{
|
||||
return ResourceType::tryFrom($value)
|
||||
?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value));
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Booking\BoardPresenter;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Htmx\HxTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* One booking board per resource kind: a month calendar next to that kind's floor plan.
|
||||
*
|
||||
* The kind is a route parameter rather than a separate controller per kind, so a new
|
||||
* ResourceType case gets a working board without touching this file.
|
||||
*/
|
||||
final class BoardController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(private readonly BoardPresenter $board)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/book/{type}', name: 'app_board', requirements: ['type' => '[a-z]+'], methods: ['GET'])]
|
||||
public function index(Request $request, string $type): Response
|
||||
{
|
||||
$resourceType = $this->resourceType($type);
|
||||
$date = DateParam::parseOrToday($request->query->getString('date'));
|
||||
|
||||
return $this->render('board/index.html.twig', $this->board->context(
|
||||
$resourceType,
|
||||
$date,
|
||||
self::timeOf($request),
|
||||
$this->currentUser(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* rather than a bare pair of fragments, so the URL stays shareable.
|
||||
*/
|
||||
#[Route('/book/{type}/day/{date}', name: 'app_board_day', requirements: ['type' => '[a-z]+', 'date' => DateParam::PATTERN], methods: ['GET'])]
|
||||
public function day(Request $request, string $type, string $date): Response
|
||||
{
|
||||
$resourceType = $this->resourceType($type);
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$time = self::timeOf($request);
|
||||
|
||||
if (!$request->headers->has('HX-Request')) {
|
||||
return $this->redirectToRoute('app_board', BoardPresenter::urlParameters($resourceType, $selected, $time));
|
||||
}
|
||||
|
||||
return $this->htmxOobResponse(
|
||||
'board/index.html.twig',
|
||||
['calendar', 'floorplan'],
|
||||
$this->board->context($resourceType, $selected, $time, $this->currentUser()),
|
||||
$this->generateUrl('app_board', BoardPresenter::urlParameters($resourceType, $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('from'), $request->query->getString('to'))
|
||||
?? TimeRange::wholeDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Route requirements cannot enumerate the enum, so {type} is matched loosely and resolved
|
||||
* here -- an unknown kind is a 404 rather than a 500.
|
||||
*/
|
||||
private function resourceType(string $value): ResourceType
|
||||
{
|
||||
return ResourceType::tryFrom($value)
|
||||
?? throw $this->createNotFoundException(\sprintf('Unknown bookable kind "%s".', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\Config\BookingHorizon;
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Exception\AlreadyHoldingResourceException;
|
||||
use App\Exception\ResourceAlreadyBookedException;
|
||||
use App\Form\BookingRequest;
|
||||
use App\Form\BookingType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Model\SeriesResult;
|
||||
use App\Model\Spot;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Routing\DateParam;
|
||||
use App\Security\Role;
|
||||
use App\Service\BoardContextFactory;
|
||||
use App\Service\BookingManager;
|
||||
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;
|
||||
|
||||
final class CreateController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardContextFactory $boardContext,
|
||||
private readonly BookingManager $manager,
|
||||
private readonly BookingRepository $bookings,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One route for both halves of the modal: GET renders it, POST handles the submit.
|
||||
*
|
||||
* 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
|
||||
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
|
||||
* fragment and the modal simply disappears.
|
||||
*/
|
||||
#[Route('/bookings/new/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, Resource $resource, string $date): Response
|
||||
{
|
||||
if (!$resource->isActive()) {
|
||||
throw $this->createNotFoundException('This resource has been retired.');
|
||||
}
|
||||
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$boardTime = $this->boardTime($request);
|
||||
|
||||
// Prefilled with the longest window still free inside the board's span -- NOT the span
|
||||
// itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered
|
||||
// as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as
|
||||
// before. Falls back to the board's span when nothing is booked at all.
|
||||
$spot = Spot::for(
|
||||
$resource,
|
||||
$this->bookings->findOverlapping($resource, $selected, $boardTime),
|
||||
$boardTime,
|
||||
$this->currentUser(),
|
||||
);
|
||||
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
|
||||
// An admin books on behalf of people and cleans up after the fact, so the three-month
|
||||
// horizon that keeps desks from being blocked half a year ahead does not bind them.
|
||||
$data->unlimitedHorizon = $this->isGranted(Role::ADMIN);
|
||||
|
||||
$form = $this->createForm(BookingType::class, $data, [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
||||
'horizon' => $data->unlimitedHorizon ? null : BookingHorizon::latest(),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
\assert($data->date instanceof \DateTimeImmutable);
|
||||
|
||||
try {
|
||||
$result = $data->repeats()
|
||||
? $this->createSeries($data, $resource)
|
||||
: null;
|
||||
|
||||
if (null === $result) {
|
||||
$this->manager->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());
|
||||
}
|
||||
|
||||
return $this->boardResponse($resource->getType(), $data->date, $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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): Response
|
||||
{
|
||||
$form->addError(new FormError($message));
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
'booking' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSeries(BookingRequest $data, Resource $resource): SeriesResult
|
||||
{
|
||||
\assert($data->date instanceof \DateTimeImmutable);
|
||||
\assert($data->repeat instanceof RecurrenceRule);
|
||||
\assert($data->until instanceof \DateTimeImmutable);
|
||||
|
||||
return $this->manager->bookSeries(new BookingSeries(
|
||||
$this->currentUser(),
|
||||
$resource,
|
||||
$data->repeat,
|
||||
$data->date,
|
||||
$data->until,
|
||||
$data->timeRange(),
|
||||
));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\Booking;
|
||||
use App\Exception\AlreadyHoldingResourceException;
|
||||
use App\Exception\ResourceAlreadyBookedException;
|
||||
use App\Form\BookingRequest;
|
||||
use App\Form\BookingType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Service\BoardContextFactory;
|
||||
use App\Service\BookingManager;
|
||||
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;
|
||||
|
||||
final class EditController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardContextFactory $boardContext,
|
||||
private readonly BookingManager $manager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, Booking $booking): Response
|
||||
{
|
||||
// Deliberately not widened for admins: rescheduling somebody else's booking is a
|
||||
// different act from freeing a desk. Admins release; they do not move.
|
||||
$this->denyUnlessOwn($booking);
|
||||
|
||||
$boardTime = $this->boardTime($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('until');
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->manager->reschedule($booking, $data->timeRange());
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
return $this->invalidModal($form, $booking, $e->getMessage());
|
||||
}
|
||||
|
||||
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
|
||||
|
||||
return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_edit.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $booking->getResource(),
|
||||
'booking' => $booking,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, Booking $booking, string $message): Response
|
||||
{
|
||||
$form->addError(new FormError($message));
|
||||
|
||||
return $this->render('booking/_modal_edit.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $booking->getResource(),
|
||||
'booking' => $booking,
|
||||
]);
|
||||
}
|
||||
|
||||
private function denyUnlessOwn(Booking $booking): void
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser())) {
|
||||
throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly BookingSeriesRepository $series,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/bookings', name: 'app_booking', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
return $this->render('booking/index.html.twig', [
|
||||
'bookings' => $this->bookings->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
|
||||
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\ConfirmedActionTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\Booking;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Security\Role;
|
||||
use App\Service\BoardContextFactory;
|
||||
use App\Service\BookingManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ReleaseController extends AbstractController
|
||||
{
|
||||
use BoardResponseTrait;
|
||||
use ConfirmedActionTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardContextFactory $boardContext,
|
||||
private readonly BookingManager $manager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 index(Request $request, Booking $booking): Response
|
||||
{
|
||||
$this->denyUnlessMayManage($booking);
|
||||
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('booking/_modal_release.html.twig', [
|
||||
'booking' => $booking,
|
||||
'csrf_token_id' => 'release-'.$booking->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
||||
|
||||
$date = $booking->getDate();
|
||||
$type = $booking->getResource()->getType();
|
||||
$label = $booking->getResource()->getLabel();
|
||||
$holder = $booking->getUser();
|
||||
$onBehalf = !$booking->belongsTo($this->currentUser());
|
||||
|
||||
if ($onBehalf) {
|
||||
// The row is about to be deleted, so without this line nothing records that an
|
||||
// administrator freed somebody else's desk.
|
||||
$this->logger->info('Admin released a booking on behalf of another user.', [
|
||||
'admin' => $this->currentUser()->getEmail(),
|
||||
'holder' => $holder->getEmail(),
|
||||
'resource' => $label,
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'time' => (string) $booking->getTimeRange(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->manager->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)));
|
||||
}
|
||||
|
||||
// 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.
|
||||
return $this->boardResponse($type, $date, $this->boardTime($request), ['calendar', 'floorplan', 'flashes', 'modal_dismiss']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Booking\AlreadyHoldingResourceException;
|
||||
use App\Booking\BoardPresenter;
|
||||
use App\Booking\BookingManager;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\Horizon;
|
||||
use App\Booking\ResourceAlreadyBookedException;
|
||||
use App\Booking\Spot;
|
||||
use App\Booking\SeriesResult;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Form\BookingRequest;
|
||||
use App\Form\BookingType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use App\Security\Role;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class BookingController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardPresenter $board,
|
||||
private readonly BookingManager $bookings,
|
||||
private readonly BookingRepository $repository,
|
||||
private readonly BookingSeriesRepository $series,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/bookings', name: 'app_booking', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One route for both halves of the modal: GET renders it, POST handles the submit.
|
||||
*
|
||||
* 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
|
||||
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
|
||||
* fragment and the modal simply disappears.
|
||||
*/
|
||||
#[Route('/bookings/new/{id}/{date}', name: 'app_booking_new', requirements: ['date' => DateParam::PATTERN], methods: ['GET', 'POST'])]
|
||||
public function create(Request $request, Resource $resource, string $date): Response
|
||||
{
|
||||
if (!$resource->isActive()) {
|
||||
throw $this->createNotFoundException('This resource has been retired.');
|
||||
}
|
||||
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$boardTime = BoardController::timeOf($request);
|
||||
|
||||
// Prefilled with the longest window still free inside the board's span -- NOT the span
|
||||
// itself. On an all-day board a resource booked 09:00-13:00 would otherwise be offered
|
||||
// as 00:00-24:00, collide with itself on submit, and leave the user exactly as stuck as
|
||||
// before. Falls back to the board's span when nothing is booked at all.
|
||||
$spot = Spot::for(
|
||||
$resource,
|
||||
$this->repository->findOverlapping($resource, $selected, $boardTime),
|
||||
$boardTime,
|
||||
$this->currentUser(),
|
||||
);
|
||||
$data = new BookingRequest($selected, $spot->suggested() ?? $boardTime);
|
||||
// An admin books on behalf of people and cleans up after the fact, so the three-month
|
||||
// horizon that keeps desks from being blocked half a year ahead does not bind them.
|
||||
$data->unlimitedHorizon = $this->isGranted(Role::ADMIN);
|
||||
|
||||
$form = $this->createForm(BookingType::class, $data, [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
||||
'horizon' => $data->unlimitedHorizon ? null : Horizon::latest(),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
\assert($data->date instanceof \DateTimeImmutable);
|
||||
|
||||
try {
|
||||
$result = $data->repeats()
|
||||
? $this->createSeries($data, $resource)
|
||||
: null;
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
return $this->boardResponse($resource->getType(), $data->date, $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('/bookings/{id}/edit', name: 'app_booking_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, Booking $booking): Response
|
||||
{
|
||||
// Deliberately not widened for admins: rescheduling somebody else's booking is a
|
||||
// different act from freeing a desk. Admins release; they do not move.
|
||||
$this->denyUnlessOwn($booking);
|
||||
|
||||
$boardTime = BoardController::timeOf($request);
|
||||
$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('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->getResource()->getType(), $booking->getDate(), $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_edit.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $booking->getResource(),
|
||||
'booking' => $booking,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET renders the confirmation modal, POST does the release -- the sibling apps' shape for
|
||||
* every destructive action (see templates/htmx_confirmation_modal.html.twig). The confirm
|
||||
* button posts back to this same URI, so `?return=` survives the round trip on its own.
|
||||
*/
|
||||
#[Route('/bookings/{id}/release', name: 'app_booking_release', methods: ['GET', 'POST'])]
|
||||
public function release(Request $request, Booking $booking): Response
|
||||
{
|
||||
$this->denyUnlessMayManage($booking);
|
||||
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('booking/_modal_release.html.twig', [
|
||||
'booking' => $booking,
|
||||
'csrf_token_id' => 'release-'.$booking->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
||||
|
||||
$date = $booking->getDate();
|
||||
$type = $booking->getResource()->getType();
|
||||
$label = $booking->getResource()->getLabel();
|
||||
$holder = $booking->getUser();
|
||||
$onBehalf = !$booking->belongsTo($this->currentUser());
|
||||
|
||||
if ($onBehalf) {
|
||||
// The row is about to be deleted, so without this line nothing records that an
|
||||
// administrator freed somebody else's desk.
|
||||
$this->logger->info('Admin released a booking on behalf of another user.', [
|
||||
'admin' => $this->currentUser()->getEmail(),
|
||||
'holder' => $holder->getEmail(),
|
||||
'resource' => $label,
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'time' => (string) $booking->getTimeRange(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->bookings->release($booking);
|
||||
|
||||
$this->addFlash('success', $onBehalf
|
||||
? \sprintf('%s freigegeben (%s).', $label, $holder->getName())
|
||||
: \sprintf('%s freigegeben.', $label));
|
||||
|
||||
// The two lists have no board fragments to swap, so they say where they want to end up
|
||||
// and get a genuine navigation. Everything else came from the board and is swapped in
|
||||
// place -- including the modal the confirmation was opened over.
|
||||
if (null !== $this->returnRoute($request)) {
|
||||
return $this->htmxRedirect($request, $this->generateUrl((string) $this->returnRoute($request)));
|
||||
}
|
||||
|
||||
return $this->boardResponse($type, $date, BoardController::timeOf($request), dismissModal: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a series from today onwards. Past occurrences are history and stay put.
|
||||
*/
|
||||
#[Route('/series/{id}/end', name: 'app_series_end', methods: ['GET', 'POST'])]
|
||||
public function endSeries(Request $request, BookingSeries $series): Response
|
||||
{
|
||||
$this->denyUnlessMayManageSeries($series);
|
||||
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('booking/_modal_end_series.html.twig', [
|
||||
'series' => $series,
|
||||
'csrf_token_id' => 'end-series-'.$series->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
|
||||
|
||||
$onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId();
|
||||
|
||||
if ($onBehalf) {
|
||||
$this->logger->info('Admin ended a series on behalf of another user.', [
|
||||
'admin' => $this->currentUser()->getEmail(),
|
||||
'holder' => $series->getUser()->getEmail(),
|
||||
'resource' => $series->getResource()->getLabel(),
|
||||
'series' => $series->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today'));
|
||||
|
||||
$this->addFlash('success', $onBehalf
|
||||
? \sprintf('Serie von %s beendet, %d Termine entfernt.', $series->getUser()->getName(), $removed)
|
||||
: \sprintf('Serie beendet, %d Termine entfernt.', $removed));
|
||||
|
||||
// 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
|
||||
{
|
||||
\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(),
|
||||
));
|
||||
}
|
||||
|
||||
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(ResourceType $type, \DateTimeImmutable $date, TimeRange $time, bool $dismissModal = false): Response
|
||||
{
|
||||
// The confirmation modal swaps *itself* away, which cannot reach the modal it was opened
|
||||
// over -- so that one is removed out of band instead. Only on this path: on every other
|
||||
// one the modal is the swap target and deleting it would leave the swap with nowhere to go.
|
||||
$blocks = $dismissModal
|
||||
? ['calendar', 'floorplan', 'flashes', 'modal_dismiss']
|
||||
: ['calendar', 'floorplan', 'flashes'];
|
||||
|
||||
return $this->htmxOobResponse(
|
||||
'board/index.html.twig',
|
||||
$blocks,
|
||||
$this->board->context($type, $date, $time, $this->currentUser()),
|
||||
$this->generateUrl('app_board', BoardPresenter::urlParameters($type, $date, $time)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function timeParameters(TimeRange $time): array
|
||||
{
|
||||
return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()];
|
||||
}
|
||||
|
||||
private function denyUnlessOwn(Booking $booking): void
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser())) {
|
||||
throw $this->createAccessDeniedException('A booking may only be changed by the person holding it.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releasing is the one thing an administrator may do to a booking that is not theirs -- a
|
||||
* colleague calls in sick and their desk has to go back into the pool.
|
||||
*/
|
||||
private function denyUnlessMayManage(Booking $booking): void
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser()) && !$this->isGranted(Role::ADMIN)) {
|
||||
throw $this->createAccessDeniedException('A booking may only be released by the person holding it or by an administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
private function denyUnlessMayManageSeries(BookingSeries $series): void
|
||||
{
|
||||
if ($series->getUser()->getId() !== $this->currentUser()->getId() && !$this->isGranted(Role::ADMIN)) {
|
||||
throw $this->createAccessDeniedException('A series may only be ended by the person who created it or by an administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to navigate after a confirmed action, or null when the caller is the board and wants
|
||||
* its fragments swapped instead. The opener says which -- the admin screen sends
|
||||
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
|
||||
* bookings.
|
||||
*/
|
||||
private function returnRoute(Request $request): ?string
|
||||
{
|
||||
return match ($request->query->get('return')) {
|
||||
'admin' => 'app_admin_booking',
|
||||
'bookings' => 'app_booking',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
|
||||
{
|
||||
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\ResourceRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The landing screen: one tile per bookable kind, plus what this person has coming up.
|
||||
*
|
||||
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
|
||||
* bookings list -- which is why the tiles are generated from ResourceType rather than written
|
||||
* out: a kind with no tile would be unreachable.
|
||||
*/
|
||||
final class IndexController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingRepository $bookings,
|
||||
private readonly ResourceRepository $resources,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
$user = $this->currentUser();
|
||||
|
||||
return $this->render('dashboard/index.html.twig', [
|
||||
'tiles' => $this->tiles($user, $today),
|
||||
'upcoming' => $this->upcoming($user, $today),
|
||||
'today' => $today,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile per bookable kind, built by walking ResourceType.
|
||||
*
|
||||
* Nothing here names a kind. Adding a case to the enum adds a tile with a working link and a
|
||||
* correct count, which is what "extensible" has to mean if it is to survive contact with the
|
||||
* next requirement.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function tiles(User $user, \DateTimeImmutable $on): array
|
||||
{
|
||||
$tiles = [];
|
||||
|
||||
foreach (ResourceType::cases() as $type) {
|
||||
$resources = $this->resources->findActiveByType($type);
|
||||
|
||||
// Availability is judged over the whole day: a resource booked for any part of it is
|
||||
// not "free today" without qualification, and the dashboard is not the place to ask
|
||||
// the user for a time span before it can say anything.
|
||||
$taken = [];
|
||||
$mine = null;
|
||||
foreach ($this->bookings->findForDate($on, null, $type) as $booking) {
|
||||
$taken[$booking->getResource()->getId()] = true;
|
||||
|
||||
if ($booking->belongsTo($user)) {
|
||||
$mine = $booking;
|
||||
}
|
||||
}
|
||||
|
||||
$tiles[] = [
|
||||
'type' => $type,
|
||||
'total' => \count($resources),
|
||||
'free' => \count($resources) - \count($taken),
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
return $tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Booking>
|
||||
*/
|
||||
private function upcoming(User $user, \DateTimeImmutable $from, int $limit = 5): array
|
||||
{
|
||||
$bookings = $this->bookings->findForUserInRange($user, $from, $from->modify('+1 year'));
|
||||
|
||||
return \array_slice($bookings, 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Booking\DashboardPresenter;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The landing screen: one tile per bookable kind, plus what this person has coming up.
|
||||
*
|
||||
* It replaces the sidebar navigation, so it is also the only route to the booking boards and the
|
||||
* bookings list -- which is why the tiles are generated from ResourceType rather than written
|
||||
* out: a kind with no tile would be unreachable.
|
||||
*/
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
use CurrentUserTrait;
|
||||
|
||||
public function __construct(private readonly DashboardPresenter $dashboard)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$today = new \DateTimeImmutable('today');
|
||||
$user = $this->currentUser();
|
||||
|
||||
return $this->render('dashboard/index.html.twig', [
|
||||
'tiles' => $this->dashboard->tiles($user, $today),
|
||||
'upcoming' => $this->dashboard->upcoming($user, $today),
|
||||
'today' => $today,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
namespace App\Controller\Dev;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -18,13 +18,13 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* environment even if the class were somehow registered.
|
||||
*/
|
||||
#[When('dev')]
|
||||
final class DevLoginController extends AbstractController
|
||||
final class LoginController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly UserRepository $users)
|
||||
{
|
||||
}
|
||||
|
||||
public function login(): Response
|
||||
public function index(): Response
|
||||
{
|
||||
if ($this->getUser()) {
|
||||
return $this->redirectToRoute('app_dashboard');
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Security;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class AuthCheckController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* The registered callback URL. Never executed: EpAuthenticator::supports() matches this
|
||||
* route and the firewall handles the request before it reaches a controller. The route has
|
||||
* to exist all the same, because the redirect URI is generated from it.
|
||||
*/
|
||||
#[Route('/auth/check', name: 'app_auth_check', methods: ['GET'])]
|
||||
public function index(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Security;
|
||||
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class AuthInitController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the authorization code flow: stores the state on the session and sends the browser
|
||||
* to myep.
|
||||
*/
|
||||
#[Route('/auth/init', name: 'app_auth_init', methods: ['GET'])]
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
if (!$this->client->isConfigured()) {
|
||||
$this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->client->authorizationUrl($request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Security;
|
||||
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class LoginController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/login', name: 'app_login', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
if ($this->getUser()) {
|
||||
return $this->redirectToRoute('app_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'oauth2_configured' => $this->client->isConfigured(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Security;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class LogoutController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Never executed: the firewall's logout listener intercepts this route.
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
public function index(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by the logout key on the main firewall.');
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Security\OAuth2\EpClient;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SecurityController extends AbstractController
|
||||
{
|
||||
public function __construct(private readonly EpClient $client)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/login', name: 'app_login', methods: ['GET'])]
|
||||
public function login(): Response
|
||||
{
|
||||
if ($this->getUser()) {
|
||||
return $this->redirectToRoute('app_dashboard');
|
||||
}
|
||||
|
||||
return $this->render('security/login.html.twig', [
|
||||
'oauth2_configured' => $this->client->isConfigured(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the authorization code flow: stores the state on the session and sends the browser
|
||||
* to myep.
|
||||
*/
|
||||
#[Route('/auth/init', name: 'app_auth_init', methods: ['GET'])]
|
||||
public function authInit(Request $request): Response
|
||||
{
|
||||
if (!$this->client->isConfigured()) {
|
||||
$this->addFlash('error', 'Für ephub ist auf myep noch kein OAuth2-Client registriert.');
|
||||
|
||||
return $this->redirectToRoute('app_login');
|
||||
}
|
||||
|
||||
return new RedirectResponse($this->client->authorizationUrl($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* The registered callback URL. Never executed: EpAuthenticator::supports() matches this
|
||||
* route and the firewall handles the request before it reaches a controller. The route has
|
||||
* to exist all the same, because the redirect URI is generated from it.
|
||||
*/
|
||||
#[Route('/auth/check', name: 'app_auth_check', methods: ['GET'])]
|
||||
public function authCheck(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by EpAuthenticator on the main firewall.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Never executed: the firewall's logout listener intercepts this route.
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
public function logout(): never
|
||||
{
|
||||
throw new \LogicException('Intercepted by the logout key on the main firewall.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Series;
|
||||
|
||||
use App\Controller\Traits\ConfirmedActionTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Security\Role;
|
||||
use App\Service\BookingManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class EndController extends AbstractController
|
||||
{
|
||||
use ConfirmedActionTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingManager $manager,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a series from today onwards. Past occurrences are history and stay put.
|
||||
*/
|
||||
#[Route('/series/{id}/end', name: 'app_series_end', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, BookingSeries $series): Response
|
||||
{
|
||||
$this->denyUnlessMayManageSeries($series);
|
||||
|
||||
if (!$request->isMethod(Request::METHOD_POST)) {
|
||||
return $this->render('booking/_modal_end_series.html.twig', [
|
||||
'series' => $series,
|
||||
'csrf_token_id' => 'end-series-'.$series->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
|
||||
|
||||
$onBehalf = $series->getUser()->getId() !== $this->currentUser()->getId();
|
||||
|
||||
if ($onBehalf) {
|
||||
$this->logger->info('Admin ended a series on behalf of another user.', [
|
||||
'admin' => $this->currentUser()->getEmail(),
|
||||
'holder' => $series->getUser()->getEmail(),
|
||||
'resource' => $series->getResource()->getLabel(),
|
||||
'series' => $series->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('today'));
|
||||
|
||||
$this->addFlash('success', $onBehalf
|
||||
? \sprintf('Serie von %s beendet, %d Termine entfernt.', $series->getUser()->getName(), $removed)
|
||||
: \sprintf('Serie beendet, %d Termine entfernt.', $removed));
|
||||
|
||||
// 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 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\TimeRange;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* What every controller that answers from the booking board shares: the span it is showing, the
|
||||
* URL that reproduces it, and the out-of-band response that swaps its fragments.
|
||||
*
|
||||
* Expects the using controller to also use CurrentUserTrait and HxTrait, and to inject
|
||||
* App\Service\BoardContextFactory as $boardContext.
|
||||
*/
|
||||
trait BoardResponseTrait
|
||||
{
|
||||
/**
|
||||
* A malformed or missing span falls back to the whole day rather than erroring: these are
|
||||
* hand-editable query parameters, not route requirements.
|
||||
*/
|
||||
private function boardTime(Request $request): TimeRange
|
||||
{
|
||||
return TimeRange::parse($request->query->getString('from'), $request->query->getString('to'))
|
||||
?? TimeRange::wholeDay();
|
||||
}
|
||||
|
||||
/**
|
||||
* The route parameters that reproduce a board view, so links and HX-Push-Url agree.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function boardUrlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array
|
||||
{
|
||||
return ['type' => $type->value, 'date' => $date->format('Y-m-d')] + $this->timeParameters($time);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function timeParameters(TimeRange $time): array
|
||||
{
|
||||
return $time->isAllDay() ? [] : ['from' => $time->start(), 'to' => $time->end()];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* @param list<string> $blocks
|
||||
*/
|
||||
private function boardResponse(
|
||||
ResourceType $type,
|
||||
\DateTimeImmutable $date,
|
||||
TimeRange $time,
|
||||
array $blocks = ['calendar', 'floorplan', 'flashes'],
|
||||
): Response {
|
||||
return $this->htmxOobResponse(
|
||||
'board/index.html.twig',
|
||||
$blocks,
|
||||
$this->boardContext->create($type, $date, $time, $this->currentUser()),
|
||||
$this->generateUrl('app_board', $this->boardUrlParameters($type, $date, $time)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* The POST half of a confirmation modal: its CSRF check, and where to go once it is confirmed.
|
||||
*/
|
||||
trait ConfirmedActionTrait
|
||||
{
|
||||
/**
|
||||
* Where to navigate after a confirmed action, or null when the caller is the board and wants
|
||||
* its fragments swapped instead. The opener says which -- the admin screen sends
|
||||
* ?return=admin so freeing somebody else's desk does not drop the administrator on their own
|
||||
* bookings.
|
||||
*/
|
||||
private function returnRoute(Request $request): ?string
|
||||
{
|
||||
return match ($request->query->get('return')) {
|
||||
'admin' => 'app_admin_booking',
|
||||
'bookings' => 'app_booking',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
|
||||
{
|
||||
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Entity\User;
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Model\TimeRange;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Model\TimeRange;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\ResourceType;
|
||||
use App\Repository\ResourceRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\ResourceType;
|
||||
use App\Repository\ResourceGroupRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How often a series repeats.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* The kinds of thing that can be booked.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How one resource looks on the floor plan, for the day and span the board is showing.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Exception;
|
||||
|
||||
use App\Entity\Booking;
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Exception;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Model\TimeRange;
|
||||
|
||||
final class ResourceAlreadyBookedException extends \RuntimeException
|
||||
{
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin;
|
||||
|
||||
use App\Booking\Horizon;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Config\BookingHorizon;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
|
||||
/**
|
||||
* What the admin booking list is narrowed by. A plain DTO bound to a GET form, so the filters
|
||||
@@ -27,7 +27,7 @@ final class BookingFilter
|
||||
// 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();
|
||||
$this->to = BookingHorizon::latest();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Admin;
|
||||
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
|
||||
@@ -4,9 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Booking\Horizon;
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Config\BookingHorizon;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use App\Model\TimeRange;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -21,7 +21,7 @@ final class BookingRequest
|
||||
public ?\DateTimeImmutable $date = null;
|
||||
|
||||
/**
|
||||
* Lifts the Horizon cap for this request. Set by the controller for ROLE_ADMIN -- a DTO
|
||||
* Lifts the BookingHorizon 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.
|
||||
*/
|
||||
@@ -110,15 +110,15 @@ final class BookingRequest
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->unlimitedHorizon && $this->until > Horizon::latest()) {
|
||||
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', Horizon::MONTHS))
|
||||
if (!$this->unlimitedHorizon && $this->until > BookingHorizon::latest()) {
|
||||
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate in die Zukunft laufen.', BookingHorizon::MONTHS))
|
||||
->atPath('until')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking horizon, measured from today rather than from the chosen day -- see Horizon.
|
||||
* The booking horizon, measured from today rather than from the chosen day -- see BookingHorizon.
|
||||
* Not an attribute constraint because it has to be liftable per role, which an attribute
|
||||
* cannot express.
|
||||
*/
|
||||
@@ -128,8 +128,8 @@ final class BookingRequest
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->date > Horizon::latest()) {
|
||||
$context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', Horizon::MONTHS))
|
||||
if ($this->date > BookingHorizon::latest()) {
|
||||
$context->buildViolation(\sprintf('Es kann höchstens %d Monate im Voraus gebucht werden.', BookingHorizon::MONTHS))
|
||||
->atPath('date')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Enum\RecurrenceRule;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
use App\Enum\SpotState;
|
||||
|
||||
/**
|
||||
* One shape on the floor plan: a resource, what is booked on it within the board's span, and
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Model;
|
||||
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Enum\SpotState;
|
||||
|
||||
/**
|
||||
* The spots of one room, in draw order.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* A span of one day, as minutes from midnight, end-exclusive.
|
||||
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Form\Admin\BookingFilter;
|
||||
use App\Model\TimeRange;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
@@ -5,8 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ResourceGroup;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Enum\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Enum\ResourceType;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Routing;
|
||||
|
||||
/**
|
||||
* The Y-m-d day identifier that every board route speaks.
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Enum\ResourceType;
|
||||
use App\Model\Spot;
|
||||
use App\Model\SpotGroup;
|
||||
use App\Model\TimeRange;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\ResourceRepository;
|
||||
|
||||
@@ -21,7 +24,7 @@ use App\Repository\ResourceRepository;
|
||||
* are carried through the occupancy counts and the plan colours alike, and the number in a
|
||||
* calendar cell always means the same thing as the colours on the plan beside it.
|
||||
*/
|
||||
final readonly class BoardPresenter
|
||||
final readonly class BoardContextFactory
|
||||
{
|
||||
private const WEEKS = 6;
|
||||
|
||||
@@ -34,7 +37,7 @@ final readonly class BoardPresenter
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function context(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array
|
||||
public function create(ResourceType $type, \DateTimeImmutable $selected, TimeRange $time, User $user): array
|
||||
{
|
||||
$selected = $selected->setTime(0, 0);
|
||||
$today = new \DateTimeImmutable('today');
|
||||
@@ -99,21 +102,4 @@ final readonly class BoardPresenter
|
||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The route parameters that reproduce a board view, so links and HX-Push-Url agree.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function urlParameters(ResourceType $type, \DateTimeImmutable $date, TimeRange $time): array
|
||||
{
|
||||
$parameters = ['type' => $type->value, 'date' => $date->format('Y-m-d')];
|
||||
|
||||
if (!$time->isAllDay()) {
|
||||
$parameters['from'] = $time->start();
|
||||
$parameters['to'] = $time->end();
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
use App\Exception\AlreadyHoldingResourceException;
|
||||
use App\Exception\ResourceAlreadyBookedException;
|
||||
use App\Model\SeriesResult;
|
||||
use App\Model\TimeRange;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
Reference in new issue
Block a user