feat: warn about overlapping bookings instead of refusing them

This commit is contained in:
fromme committed 2026-09-18 09:40:14 +02:00
1 parent fae28b1fd7
commit 5a6db29205
8 files changed
+359 -71

No files matched your search

+6 -3
View File
@@ -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());
}
+7 -2
View File
@@ -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);
}
}