feat: warn about overlapping bookings instead of refusing them
This commit is contained in:
1 parent
fae28b1fd7
commit
5a6db29205
8 files changed
+359
-71
No files matched your search
@@ -7,10 +7,10 @@ namespace App\Controller\Booking;
|
||||
use App\Config\BookingHorizon;
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Controller\Traits\OverlapWarningTrait;
|
||||
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;
|
||||
@@ -34,6 +34,7 @@ final class CreateController extends AbstractController
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
use OverlapWarningTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardContextFactory $boardContext,
|
||||
@@ -91,12 +92,14 @@ final class CreateController extends AbstractController
|
||||
: null;
|
||||
|
||||
if (null === $result) {
|
||||
$this->manager->book($this->currentUser(), $resource, $data->date, $data->timeRange());
|
||||
$booking = $this->manager->book($this->currentUser(), $resource, $data->date, $data->timeRange());
|
||||
$this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange()));
|
||||
$this->warnAboutOverlaps($booking);
|
||||
} else {
|
||||
$this->flashSeriesResult($result, $resource);
|
||||
$this->warnAboutSeriesOverlaps($result);
|
||||
}
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
} catch (ResourceAlreadyBookedException $e) {
|
||||
return $this->invalidModal($form, $resource, $e->getMessage());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,13 @@ namespace App\Controller\Booking;
|
||||
|
||||
use App\Controller\Traits\BoardResponseTrait;
|
||||
use App\Controller\Traits\CurrentUserTrait;
|
||||
use App\Controller\Traits\OverlapWarningTrait;
|
||||
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\Repository\BookingRepository;
|
||||
use App\Service\BoardContextFactory;
|
||||
use App\Service\BookingManager;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -26,10 +27,12 @@ final class EditController extends AbstractController
|
||||
use BoardResponseTrait;
|
||||
use CurrentUserTrait;
|
||||
use HxTrait;
|
||||
use OverlapWarningTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BoardContextFactory $boardContext,
|
||||
private readonly BookingManager $manager,
|
||||
private readonly BookingRepository $bookings,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -58,11 +61,13 @@ final class EditController extends AbstractController
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->manager->reschedule($booking, $data->timeRange());
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
} catch (ResourceAlreadyBookedException $e) {
|
||||
return $this->invalidModal($form, $booking, $e->getMessage());
|
||||
}
|
||||
|
||||
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
|
||||
// Moving a booking can create an overlap that was not there when it was made.
|
||||
$this->warnAboutOverlaps($booking);
|
||||
|
||||
return $this->boardResponse($booking->getResource()->getType(), $booking->getDate(), $boardTime);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Model\SeriesResult;
|
||||
|
||||
/**
|
||||
* The hint shown after somebody books a second resource of the same kind for a span they already
|
||||
* hold one for.
|
||||
*
|
||||
* Holding two desks at once is allowed -- one of them is usually for the customer you are
|
||||
* meeting -- so this is not a guard and never refuses anything; BookingManager explains what is
|
||||
* still refused. It only says out loud what just happened, because the alternative is finding
|
||||
* out on the day.
|
||||
*
|
||||
* Only same-kind overlaps are reported. A desk next to a parking space next to a monitor is the
|
||||
* everyday combination, and a toast on every one of those would train everybody to ignore the
|
||||
* toast that matters.
|
||||
*
|
||||
* Expects the using controller to extend AbstractController and to inject
|
||||
* App\Repository\BookingRepository as $bookings.
|
||||
*/
|
||||
trait OverlapWarningTrait
|
||||
{
|
||||
private function warnAboutOverlaps(Booking $booking): void
|
||||
{
|
||||
$clashes = $this->bookings->findOverlappingForUser(
|
||||
$booking->getUser(),
|
||||
$booking->getDate(),
|
||||
$booking->getTimeRange(),
|
||||
$booking->getResource()->getType(),
|
||||
$booking,
|
||||
);
|
||||
|
||||
if ([] === $clashes) {
|
||||
return;
|
||||
}
|
||||
|
||||
$day = $booking->getDate()->format('d.m.Y');
|
||||
|
||||
if (1 === \count($clashes)) {
|
||||
$this->addFlash('warning', \sprintf(
|
||||
'Hinweis: Du hast am %s zur gleichen Zeit bereits %s gebucht (%s).',
|
||||
$day,
|
||||
$clashes[0]->getResource()->getLabel(),
|
||||
$clashes[0]->getTimeRange(),
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addFlash('warning', \sprintf(
|
||||
'Hinweis: Du hast am %s zur gleichen Zeit bereits %d weitere %s gebucht: %s.',
|
||||
$day,
|
||||
\count($clashes),
|
||||
$booking->getResource()->getType()->plural(),
|
||||
implode(', ', self::labelsOf($clashes)),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same hint for a whole series, aggregated into one flash -- a weekly series running to
|
||||
* the horizon must not fire a toast per occurrence.
|
||||
*/
|
||||
private function warnAboutSeriesOverlaps(SeriesResult $result): void
|
||||
{
|
||||
if ($result->isCompletelyBlocked()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$series = $result->series;
|
||||
$created = $result->created;
|
||||
$type = $series->getResource()->getType();
|
||||
|
||||
$clashes = $this->bookings->findOverlappingForUserInRange(
|
||||
$series->getUser(),
|
||||
$created[0]->getDate(),
|
||||
$created[array_key_last($created)]->getDate(),
|
||||
$series->getTimeRange(),
|
||||
$type,
|
||||
$series,
|
||||
);
|
||||
|
||||
// One query covers the whole window, so it also returns days between two occurrences --
|
||||
// days this series never landed on. Only the ones it did are news.
|
||||
$days = [];
|
||||
foreach ($clashes as $clash) {
|
||||
$days[$clash->getDate()->format('Y-m-d')][] = $clash;
|
||||
}
|
||||
|
||||
$days = array_intersect_key($days, array_flip(array_map(
|
||||
static fn (Booking $booking): string => $booking->getDate()->format('Y-m-d'),
|
||||
$created,
|
||||
)));
|
||||
|
||||
if ([] === $days) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (1 === \count($days)) {
|
||||
$date = array_key_first($days);
|
||||
|
||||
$this->addFlash('warning', \sprintf(
|
||||
'Hinweis: Am %s hast du zur gleichen Zeit bereits %s gebucht.',
|
||||
(new \DateTimeImmutable($date))->format('j.n.'),
|
||||
implode(', ', self::labelsOf($days[$date])),
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->addFlash('warning', \sprintf(
|
||||
'Hinweis: An %d von %d Terminen hast du zur gleichen Zeit bereits andere %s gebucht (%s).',
|
||||
\count($days),
|
||||
\count($created),
|
||||
$type->plural(),
|
||||
self::shortDates(array_keys($days)),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Booking> $bookings
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function labelsOf(array $bookings): array
|
||||
{
|
||||
return array_values(array_unique(array_map(
|
||||
static fn (Booking $booking): string => $booking->getResource()->getLabel(),
|
||||
$bookings,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut off after a handful: a series can clash on two dozen days, and a toast listing them all
|
||||
* would cover the board it is reporting on.
|
||||
*
|
||||
* @param list<string> $dates Y-m-d
|
||||
*/
|
||||
private static function shortDates(array $dates): string
|
||||
{
|
||||
$shown = \array_slice($dates, 0, 5);
|
||||
$formatted = array_map(
|
||||
static fn (string $date): string => (new \DateTimeImmutable($date))->format('j.n.'),
|
||||
$shown,
|
||||
);
|
||||
|
||||
if (\count($dates) > \count($shown)) {
|
||||
$formatted[] = '…';
|
||||
}
|
||||
|
||||
return implode(', ', $formatted);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user