feat: add booking time ranges and repeating series
This commit is contained in:
1 parent
59feca2f8e
commit
d433d271d0
29 files changed
+2135
-180
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
/*
|
||||
* Shows and hides the parts of the booking form that only apply in some states: the time fields
|
||||
* when "ganzer Tag" is off, the recurrence fields when a repetition is picked, and the weekday
|
||||
* boxes only for a weekly rule.
|
||||
*
|
||||
* Purely client-side, so it is Stimulus rather than an htmx form refresh -- there is nothing the
|
||||
* server needs to decide here. The server still validates every one of these rules, because a
|
||||
* hidden field is not an absent one.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['times', 'repeat', 'weekdays'];
|
||||
|
||||
toggleTimes(event) {
|
||||
this.timesTarget.classList.toggle('hidden', event.target.checked);
|
||||
}
|
||||
|
||||
toggleRepeat(event) {
|
||||
const rule = event.target.value;
|
||||
|
||||
this.repeatTarget.classList.toggle('hidden', rule === '');
|
||||
this.weekdaysTarget.classList.toggle('hidden', rule !== 'weekly');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260913133131 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Adds time ranges and recurring series to bookings, and drops the unique (resource, date) guard';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE booking_series (id INT AUTO_INCREMENT NOT NULL, rule VARCHAR(255) NOT NULL, weekdays JSON NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, start_minute INT NOT NULL, end_minute INT NOT NULL, created_at DATETIME NOT NULL, user_id INT NOT NULL, resource_id INT NOT NULL, INDEX IDX_818A3AD3A76ED395 (user_id), INDEX IDX_818A3AD389329D25 (resource_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
$this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD3A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE booking_series ADD CONSTRAINT FK_818A3AD389329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE RESTRICT');
|
||||
// The unique index cannot express "no overlapping time range" -- and MariaDB has no
|
||||
// exclusion constraints -- so the guard moves into BookingManager, which serialises on a
|
||||
// SELECT ... FOR UPDATE of the resource row. This index makes that check cheap.
|
||||
$this->addSql('ALTER TABLE booking DROP INDEX uniq_booking_resource_date, ADD INDEX idx_booking_resource_date (resource_id, date)');
|
||||
// The defaults are what backfills existing rows: every booking made so far was a whole
|
||||
// day, which is exactly 0..1440. They are dropped again immediately so the application
|
||||
// stays the only thing that decides a booking's span.
|
||||
$this->addSql('ALTER TABLE booking ADD start_minute INT NOT NULL DEFAULT 0, ADD end_minute INT NOT NULL DEFAULT 1440, ADD overridden TINYINT(1) NOT NULL DEFAULT 0, ADD series_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE booking ALTER COLUMN start_minute DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE booking ALTER COLUMN end_minute DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE booking ALTER COLUMN overridden DROP DEFAULT');
|
||||
$this->addSql('ALTER TABLE booking ADD CONSTRAINT FK_E00CEDDE5278319C FOREIGN KEY (series_id) REFERENCES booking_series (id) ON DELETE SET NULL');
|
||||
$this->addSql('CREATE INDEX idx_booking_user_date ON booking (user_id, date)');
|
||||
$this->addSql('CREATE INDEX IDX_E00CEDDE5278319C ON booking (series_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Note the down() is lossy by nature: restoring UNIQUE(resource_id, date) fails if any
|
||||
// day holds two part-day bookings of the same resource, which is the whole point of this
|
||||
// migration. Release those first if you really need to roll back.
|
||||
$this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD3A76ED395');
|
||||
$this->addSql('ALTER TABLE booking_series DROP FOREIGN KEY FK_818A3AD389329D25');
|
||||
$this->addSql('DROP TABLE booking_series');
|
||||
$this->addSql('ALTER TABLE booking DROP INDEX idx_booking_resource_date, ADD UNIQUE INDEX uniq_booking_resource_date (resource_id, date)');
|
||||
$this->addSql('ALTER TABLE booking DROP FOREIGN KEY FK_E00CEDDE5278319C');
|
||||
$this->addSql('DROP INDEX idx_booking_user_date ON booking');
|
||||
$this->addSql('DROP INDEX IDX_E00CEDDE5278319C ON booking');
|
||||
$this->addSql('ALTER TABLE booking DROP start_minute, DROP end_minute, DROP overridden, DROP series_id');
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,20 @@ namespace App\Booking;
|
||||
use App\Entity\Booking;
|
||||
|
||||
/**
|
||||
* One person may hold at most one desk and one parking spot per day.
|
||||
* One person may not hold two resources of the same kind at overlapping times. A desk in the
|
||||
* morning and a different one in the afternoon is fine -- only the overlap is refused.
|
||||
*/
|
||||
final class AlreadyHoldingResourceException extends \RuntimeException
|
||||
{
|
||||
public function __construct(public readonly Booking $existing)
|
||||
{
|
||||
$time = $existing->getTimeRange();
|
||||
|
||||
parent::__construct(\sprintf(
|
||||
'Du hast am %s bereits %s gebucht.',
|
||||
'Du hast am %s bereits %s gebucht (%s).',
|
||||
$existing->getDate()->format('d.m.Y'),
|
||||
$existing->getResource()->getLabel(),
|
||||
$time,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
@@ -15,7 +14,12 @@ use App\Repository\ResourceRepository;
|
||||
* calendar and the floor plan.
|
||||
*
|
||||
* Both are always rendered from the same context so that one htmx round trip can swap both --
|
||||
* picking a day updates the grid and the plan in a single out-of-band response.
|
||||
* picking a day (or a time span) updates the grid and the plan in a single out-of-band response.
|
||||
*
|
||||
* Everything here is relative to a TimeRange. With part-day bookings "is this desk free?" has no
|
||||
* answer on its own, only "is it free between 09:00 and 13:00?", so the span the user picked is
|
||||
* carried through the occupancy counts and the plan colours alike -- the number in a calendar
|
||||
* cell always means the same thing as the colours on the plan.
|
||||
*/
|
||||
final readonly class BoardPresenter
|
||||
{
|
||||
@@ -30,7 +34,7 @@ final readonly class BoardPresenter
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function context(\DateTimeImmutable $selected, User $user): array
|
||||
public function context(\DateTimeImmutable $selected, TimeRange $time, User $user): array
|
||||
{
|
||||
$selected = $selected->setTime(0, 0);
|
||||
$today = new \DateTimeImmutable('today');
|
||||
@@ -39,7 +43,7 @@ final readonly class BoardPresenter
|
||||
$gridStart = $monthStart->modify('monday this week');
|
||||
$gridEnd = $gridStart->modify(\sprintf('+%d days', self::WEEKS * 7 - 1));
|
||||
|
||||
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd);
|
||||
$counts = $this->bookings->countPerDateInRange($gridStart, $gridEnd, $time);
|
||||
|
||||
$mine = [];
|
||||
foreach ($this->bookings->findForUserInRange($user, $gridStart, $gridEnd) as $booking) {
|
||||
@@ -68,29 +72,39 @@ final readonly class BoardPresenter
|
||||
}
|
||||
|
||||
$byResource = [];
|
||||
foreach ($this->bookings->findForDate($selected) as $booking) {
|
||||
$byResource[$booking->getResource()->getId()] = $booking;
|
||||
foreach ($this->bookings->findForDate($selected, $time) as $booking) {
|
||||
// Several bookings can overlap the span on one resource once times are in play; the
|
||||
// first is enough to colour the shape, and the tooltip lists them all.
|
||||
$byResource[$booking->getResource()->getId()][] = $booking;
|
||||
}
|
||||
|
||||
return [
|
||||
'selected_date' => $selected,
|
||||
'selected_time' => $time,
|
||||
'month_start' => $monthStart,
|
||||
'weeks' => $weeks,
|
||||
'is_past' => $selected < $today,
|
||||
'desks' => $this->resources->findActiveByType(ResourceType::DESK),
|
||||
'parking' => $this->resources->findActiveByType(ResourceType::PARKING),
|
||||
'bookings_by_resource' => $byResource,
|
||||
'my_bookings_today' => $this->mineOn($selected, $mine),
|
||||
'my_bookings_today' => $mine[$selected->format('Y-m-d')] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, list<Booking>> $mine
|
||||
* The query parameters that reproduce a board view, so links and HX-Push-Url agree.
|
||||
*
|
||||
* @return list<Booking>
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function mineOn(\DateTimeImmutable $date, array $mine): array
|
||||
public static function urlParameters(\DateTimeImmutable $date, TimeRange $time): array
|
||||
{
|
||||
return $mine[$date->format('Y-m-d')] ?? [];
|
||||
$parameters = ['tag' => $date->format('Y-m-d')];
|
||||
|
||||
if (!$time->isAllDay()) {
|
||||
$parameters['von'] = $time->start();
|
||||
$parameters['bis'] = $time->end();
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
}
|
||||
+147
-24
@@ -5,12 +5,33 @@ declare(strict_types=1);
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\DBAL\LockMode;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* Every write to a booking goes through here, because every write needs the same guard.
|
||||
*
|
||||
* **How double-booking is prevented, and why it changed.** While a booking was a whole day, a
|
||||
* UNIQUE(resource_id, date) index did the job and the database rejected the loser of a race. A
|
||||
* booking is now a time *range*, and an overlap is not something a unique index can express --
|
||||
* nor does MariaDB have PostgreSQL's exclusion constraints. So the serialisation point moved
|
||||
* into the application: each write opens a transaction, takes a row lock on the Resource
|
||||
* (SELECT ... FOR UPDATE), and only then looks for overlaps. Two concurrent requests for the
|
||||
* same resource queue up on that lock, so the second one sees the first one's row.
|
||||
*
|
||||
* This is still a real guard rather than the racy check-then-insert the plan warned about -- the
|
||||
* check happens while nobody else can be inserting for that resource. The cost is that the
|
||||
* protection now lives in code, so it belongs to this class alone: nothing else may persist a
|
||||
* Booking.
|
||||
*
|
||||
* The per-user rule ("not two desks at once") is deliberately NOT covered by the lock: it spans
|
||||
* resources, and locking every resource would serialise the whole app to protect against a user
|
||||
* racing themselves in two browser tabs.
|
||||
*/
|
||||
final readonly class BookingManager
|
||||
{
|
||||
public function __construct(
|
||||
@@ -20,36 +41,84 @@ final readonly class BookingManager
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AlreadyHoldingResourceException if the user already holds a resource of this type that day
|
||||
* @throws ResourceAlreadyBookedException if somebody else holds this resource that day
|
||||
* @throws AlreadyHoldingResourceException if the user already holds a resource of this kind in that span
|
||||
* @throws ResourceAlreadyBookedException if somebody else does
|
||||
*/
|
||||
public function book(User $user, Resource $resource, \DateTimeImmutable $date): Booking
|
||||
public function book(User $user, Resource $resource, \DateTimeImmutable $date, ?TimeRange $time = null): Booking
|
||||
{
|
||||
$date = $date->setTime(0, 0);
|
||||
// All day is the default, as it was before ranges existed.
|
||||
$time ??= TimeRange::wholeDay();
|
||||
|
||||
$existing = $this->bookings->findOneForUserDateAndType($user, $date, $resource->getType());
|
||||
if (null !== $existing) {
|
||||
throw new AlreadyHoldingResourceException($existing);
|
||||
}
|
||||
return $this->em->wrapInTransaction(function () use ($user, $resource, $date, $time): Booking {
|
||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
// Friendly path: almost every collision is caught here, with the entity manager healthy.
|
||||
if (null !== $this->bookings->findOneBy(['resource' => $resource, 'date' => $date])) {
|
||||
throw new ResourceAlreadyBookedException($resource, $date);
|
||||
}
|
||||
$this->assertFree($user, $resource, $date, $time);
|
||||
|
||||
$booking = new Booking($user, $resource, $date);
|
||||
$this->em->persist($booking);
|
||||
|
||||
try {
|
||||
$booking = new Booking($user, $resource, $date, $time);
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
} catch (UniqueConstraintViolationException $e) {
|
||||
// The real guard: two concurrent requests both pass the check above and the database
|
||||
// rejects the second. A failed flush CLOSES the entity manager, so whoever catches
|
||||
// this must render its error page without touching the database again.
|
||||
throw new ResourceAlreadyBookedException($resource, $date, $e);
|
||||
}
|
||||
|
||||
return $booking;
|
||||
return $booking;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialises a series into concrete bookings, skipping days that are already taken.
|
||||
*
|
||||
* The resource is locked once for the whole series rather than per occurrence, so the set of
|
||||
* days either all see the same world or none do. Occurrences fall on distinct days, so the
|
||||
* not-yet-flushed ones cannot collide with each other.
|
||||
*/
|
||||
public function bookSeries(BookingSeries $series): SeriesResult
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($series): SeriesResult {
|
||||
$resource = $series->getResource();
|
||||
$user = $series->getUser();
|
||||
$time = $series->getTimeRange();
|
||||
|
||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
$created = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($series->occurrences() as $date) {
|
||||
try {
|
||||
$this->assertFree($user, $resource, $date, $time);
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
$skipped[$date->format('Y-m-d')] = $e->getMessage();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$booking = new Booking($user, $resource, $date, $time, $series);
|
||||
$this->em->persist($booking);
|
||||
$created[] = $booking;
|
||||
}
|
||||
|
||||
if ([] !== $created) {
|
||||
$this->em->persist($series);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return new SeriesResult($series, $created, $skipped);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one occurrence to different times, leaving the rest of its series alone.
|
||||
*/
|
||||
public function reschedule(Booking $booking, TimeRange $time): void
|
||||
{
|
||||
$this->em->wrapInTransaction(function () use ($booking, $time): void {
|
||||
$resource = $booking->getResource();
|
||||
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
|
||||
|
||||
$this->assertFree($booking->getUser(), $resource, $booking->getDate(), $time, $booking);
|
||||
|
||||
$booking->changeTime($time);
|
||||
$this->em->flush();
|
||||
});
|
||||
}
|
||||
|
||||
public function release(Booking $booking): void
|
||||
@@ -57,4 +126,58 @@ final readonly class BookingManager
|
||||
$this->em->remove($booking);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a series from a day onwards: its remaining occurrences go, and the series itself is
|
||||
* either shortened or dropped if nothing is left of it.
|
||||
*
|
||||
* @return int the number of occurrences removed
|
||||
*/
|
||||
public function endSeries(BookingSeries $series, \DateTimeImmutable $from): int
|
||||
{
|
||||
return $this->em->wrapInTransaction(function () use ($series, $from): int {
|
||||
$from = $from->setTime(0, 0);
|
||||
$removed = 0;
|
||||
|
||||
foreach ($this->bookings->findForSeriesFrom($series, $from) as $booking) {
|
||||
$this->em->remove($booking);
|
||||
++$removed;
|
||||
}
|
||||
|
||||
if ($series->getStartDate() >= $from) {
|
||||
$this->em->remove($series);
|
||||
} else {
|
||||
$series->setEndDate($from->modify('-1 day'));
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $removed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AlreadyHoldingResourceException
|
||||
* @throws ResourceAlreadyBookedException
|
||||
*/
|
||||
private function assertFree(
|
||||
User $user,
|
||||
Resource $resource,
|
||||
\DateTimeImmutable $date,
|
||||
TimeRange $time,
|
||||
?Booking $ignore = null,
|
||||
): void {
|
||||
$mine = $this->bookings->findOverlappingForUser($user, $date, $time, $resource->getType(), $ignore);
|
||||
if (null !== $mine) {
|
||||
throw new AlreadyHoldingResourceException($mine);
|
||||
}
|
||||
|
||||
foreach ($this->bookings->findOverlapping($resource, $date, $time) as $clash) {
|
||||
if (null !== $ignore && $clash->getId() === $ignore->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new ResourceAlreadyBookedException($resource, $date, $clash->getTimeRange());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
enum RecurrenceRule: string
|
||||
{
|
||||
case DAILY = 'daily';
|
||||
case WEEKLY = 'weekly';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::DAILY => 'Täglich',
|
||||
self::WEEKLY => 'Wöchentlich',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,17 @@ final class ResourceAlreadyBookedException extends \RuntimeException
|
||||
public function __construct(
|
||||
public readonly Resource $resource,
|
||||
public readonly \DateTimeImmutable $date,
|
||||
?\Throwable $previous = null,
|
||||
public readonly ?TimeRange $clashingWith = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
\sprintf('%s ist am %s bereits belegt.', $resource->getLabel(), $date->format('d.m.Y')),
|
||||
previous: $previous,
|
||||
);
|
||||
$when = null === $clashingWith || $clashingWith->isAllDay()
|
||||
? ''
|
||||
: \sprintf(' (%s)', $clashingWith);
|
||||
|
||||
parent::__construct(\sprintf(
|
||||
'%s ist am %s bereits belegt%s.',
|
||||
$resource->getLabel(),
|
||||
$date->format('d.m.Y'),
|
||||
$when,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
|
||||
/**
|
||||
* What creating a series actually achieved. A series is materialised best-effort: days that were
|
||||
* already taken are skipped and reported, rather than failing the whole series.
|
||||
*/
|
||||
final readonly class SeriesResult
|
||||
{
|
||||
/**
|
||||
* @param list<Booking> $created
|
||||
* @param array<string, string> $skipped Y-m-d => reason
|
||||
*/
|
||||
public function __construct(
|
||||
public BookingSeries $series,
|
||||
public array $created,
|
||||
public array $skipped,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createdCount(): int
|
||||
{
|
||||
return \count($this->created);
|
||||
}
|
||||
|
||||
public function skippedCount(): int
|
||||
{
|
||||
return \count($this->skipped);
|
||||
}
|
||||
|
||||
public function isCompletelyBlocked(): bool
|
||||
{
|
||||
return [] === $this->created;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function skippedDates(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (string $date): string => (new \DateTimeImmutable($date))->format('j.n.'),
|
||||
array_keys($this->skipped),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Booking;
|
||||
|
||||
/**
|
||||
* A span of one day, as minutes from midnight, end-exclusive.
|
||||
*
|
||||
* Minutes rather than a TIME column on purpose: the end of an all-day booking is 24:00, which
|
||||
* TIME cannot express, and half-open integer arithmetic makes the overlap test a plain
|
||||
* comparison in both PHP and DQL.
|
||||
*/
|
||||
final readonly class TimeRange implements \Stringable
|
||||
{
|
||||
public const DAY_START = 0;
|
||||
public const DAY_END = 1440;
|
||||
|
||||
private function __construct(
|
||||
public int $startMinute,
|
||||
public int $endMinute,
|
||||
) {
|
||||
if ($startMinute < self::DAY_START || $endMinute > self::DAY_END) {
|
||||
throw new \InvalidArgumentException('Eine Buchung muss innerhalb eines Tages liegen.');
|
||||
}
|
||||
|
||||
if ($startMinute >= $endMinute) {
|
||||
throw new \InvalidArgumentException('Das Ende muss nach dem Beginn liegen.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Named wholeDay() and not allDay() on purpose. Twig resolves `range.allDay` by looking for a
|
||||
* method literally called `allDay` BEFORE it tries `isAllDay()`, and it does not care that
|
||||
* the method is static -- so a factory of that name silently shadows the accessor and every
|
||||
* template reads back a fresh all-day range, which is truthy. Keep the two names distinct.
|
||||
*/
|
||||
public static function wholeDay(): self
|
||||
{
|
||||
return new self(self::DAY_START, self::DAY_END);
|
||||
}
|
||||
|
||||
public static function fromMinutes(int $startMinute, int $endMinute): self
|
||||
{
|
||||
return new self($startMinute, $endMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the clock time off two \DateTime values, ignoring their date part -- which is what
|
||||
* the form's TimeType hands over.
|
||||
*/
|
||||
public static function fromTimes(\DateTimeInterface $start, \DateTimeInterface $end): self
|
||||
{
|
||||
return new self(self::minutesOf($start), self::minutesOf($end));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the board's ?von=&bis= pair. Returns null for anything missing or malformed, so the
|
||||
* caller can fall back to the whole day rather than 404 on a hand-edited URL.
|
||||
*/
|
||||
public static function parse(?string $start, ?string $end): ?self
|
||||
{
|
||||
$hasStart = null !== $start && '' !== $start;
|
||||
$hasEnd = null !== $end && '' !== $end;
|
||||
|
||||
if (!$hasStart && !$hasEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// One-sided input is treated as open to that end of the day, so typing only a start time
|
||||
// does something visible instead of silently resetting the board.
|
||||
$startMinute = $hasStart ? self::parseClock($start) : self::DAY_START;
|
||||
$endMinute = $hasEnd ? self::parseClock($end) : self::DAY_END;
|
||||
|
||||
if (null === $startMinute || null === $endMinute || $startMinute >= $endMinute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self($startMinute, $endMinute);
|
||||
}
|
||||
|
||||
private static function parseClock(string $value): ?int
|
||||
{
|
||||
if (1 !== preg_match('/^([01]\d|2[0-4]):([0-5]\d)$/', $value, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minutes = (int) $matches[1] * 60 + (int) $matches[2];
|
||||
|
||||
return $minutes > self::DAY_END ? null : $minutes;
|
||||
}
|
||||
|
||||
public function isAllDay(): bool
|
||||
{
|
||||
return self::DAY_START === $this->startMinute && self::DAY_END === $this->endMinute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Half-open overlap: 09:00-13:00 and 13:00-17:00 do NOT collide.
|
||||
*/
|
||||
public function overlaps(self $other): bool
|
||||
{
|
||||
return $this->startMinute < $other->endMinute && $other->startMinute < $this->endMinute;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->startMinute === $other->startMinute && $this->endMinute === $other->endMinute;
|
||||
}
|
||||
|
||||
public function start(): string
|
||||
{
|
||||
return self::format($this->startMinute);
|
||||
}
|
||||
|
||||
public function end(): string
|
||||
{
|
||||
return self::format($this->endMinute);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->isAllDay() ? 'ganztägig' : $this->start().'–'.$this->end();
|
||||
}
|
||||
|
||||
private static function minutesOf(\DateTimeInterface $time): int
|
||||
{
|
||||
return (int) $time->format('G') * 60 + (int) $time->format('i');
|
||||
}
|
||||
|
||||
private static function format(int $minutes): string
|
||||
{
|
||||
return \sprintf('%02d:%02d', intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,19 @@ use App\Booking\BoardPresenter;
|
||||
use App\Booking\BookingManager;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\ResourceAlreadyBookedException;
|
||||
use App\Booking\SeriesResult;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\Resource;
|
||||
use App\Form\BookingRequest;
|
||||
use App\Form\BookingType;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Repository\BookingRepository;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -30,6 +35,7 @@ final class BookingController extends AbstractController
|
||||
private readonly BoardPresenter $board,
|
||||
private readonly BookingManager $bookings,
|
||||
private readonly BookingRepository $repository,
|
||||
private readonly BookingSeriesRepository $series,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,6 +46,7 @@ final class BookingController extends AbstractController
|
||||
|
||||
return $this->render('booking/index.html.twig', [
|
||||
'bookings' => $this->repository->findForUserInRange($this->currentUser(), $today, $today->modify('+1 year')),
|
||||
'series' => $this->series->findActiveForUser($this->currentUser(), $today),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -48,7 +55,7 @@ final class BookingController extends AbstractController
|
||||
*
|
||||
* On an invalid submit this just render()s the modal again -- Symfony turns that into a 422
|
||||
* and the responseHandling entry in assets/app.js lets htmx swap it, so the modal replaces
|
||||
* itself with the errors showing. On success the response carries nothing but the two
|
||||
* itself with the errors showing. On success the response carries nothing but the
|
||||
* out-of-band board blocks, so the modal's outerHTML target is swapped with an empty
|
||||
* fragment and the modal simply disappears.
|
||||
*/
|
||||
@@ -60,34 +67,36 @@ final class BookingController extends AbstractController
|
||||
}
|
||||
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$boardTime = DashboardController::timeOf($request);
|
||||
|
||||
$form = $this->createForm(BookingType::class, new BookingRequest($selected), [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date]),
|
||||
// Prefilled from whatever span the board is showing, so narrowing the board and then
|
||||
// clicking a desk books exactly what was on screen.
|
||||
$data = new BookingRequest($selected, $boardTime);
|
||||
|
||||
$form = $this->createForm(BookingType::class, $data, [
|
||||
'action' => $this->generateUrl('app_booking_new', ['id' => $resource->getId(), 'date' => $date] + $this->timeParameters($boardTime)),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$requested = $form->getData();
|
||||
\assert($requested instanceof BookingRequest);
|
||||
\assert($requested->date instanceof \DateTimeImmutable);
|
||||
\assert($data->date instanceof \DateTimeImmutable);
|
||||
|
||||
try {
|
||||
$this->bookings->book($this->currentUser(), $resource, $requested->date);
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
// Deliberately renders from data already in hand: the losing side of a race has
|
||||
// a CLOSED entity manager at this point (see BookingManager::book), so this
|
||||
// branch must not query.
|
||||
$form->addError(new FormError($e->getMessage()));
|
||||
$result = $data->repeats()
|
||||
? $this->createSeries($data, $resource)
|
||||
: null;
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $resource,
|
||||
]);
|
||||
if (null === $result) {
|
||||
$this->bookings->book($this->currentUser(), $resource, $data->date, $data->timeRange());
|
||||
$this->addFlash('success', \sprintf('%s gebucht (%s).', $resource->getLabel(), $data->timeRange()));
|
||||
} else {
|
||||
$this->flashSeriesResult($result, $resource);
|
||||
}
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
return $this->invalidModal($form, $resource, $e->getMessage());
|
||||
}
|
||||
|
||||
$this->addFlash('success', \sprintf('%s gebucht.', $resource->getLabel()));
|
||||
|
||||
return $this->boardResponse($requested->date);
|
||||
return $this->boardResponse($data->date, $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_create.html.twig', [
|
||||
@@ -96,16 +105,50 @@ final class BookingController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one occurrence, which is what an "override" of a series is: the other occurrences
|
||||
* are untouched and this one is flagged as differing from its series.
|
||||
*/
|
||||
#[Route('/buchungen/{id}/bearbeiten', name: 'app_booking_edit', methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, Booking $booking): Response
|
||||
{
|
||||
$this->denyUnlessOwn($booking);
|
||||
|
||||
$boardTime = DashboardController::timeOf($request);
|
||||
$data = new BookingRequest($booking->getDate(), $booking->getTimeRange());
|
||||
|
||||
$form = $this->createForm(BookingType::class, $data, [
|
||||
'action' => $this->generateUrl('app_booking_edit', ['id' => $booking->getId()] + $this->timeParameters($boardTime)),
|
||||
]);
|
||||
// The day and any recurrence belong to the original booking; only the span is editable
|
||||
// here, so a series is never silently rewritten by editing one of its occurrences.
|
||||
$form->remove('date')->remove('repeat')->remove('weekdays')->remove('until');
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
try {
|
||||
$this->bookings->reschedule($booking, $data->timeRange());
|
||||
} catch (AlreadyHoldingResourceException|ResourceAlreadyBookedException $e) {
|
||||
return $this->invalidModal($form, $booking->getResource(), $e->getMessage(), $booking);
|
||||
}
|
||||
|
||||
$this->addFlash('success', \sprintf('%s geändert (%s).', $booking->getResource()->getLabel(), $booking->getTimeRange()));
|
||||
|
||||
return $this->boardResponse($booking->getDate(), $boardTime);
|
||||
}
|
||||
|
||||
return $this->render('booking/_modal_edit.html.twig', [
|
||||
'form' => $form,
|
||||
'resource' => $booking->getResource(),
|
||||
'booking' => $booking,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/buchungen/{id}/freigeben', name: 'app_booking_release', methods: ['POST'])]
|
||||
public function release(Request $request, Booking $booking): Response
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser())) {
|
||||
throw $this->createAccessDeniedException('Fremde Buchungen können nicht freigegeben werden.');
|
||||
}
|
||||
|
||||
if (!$this->isCsrfTokenValid('release-'.$booking->getId(), $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
|
||||
}
|
||||
$this->denyUnlessOwn($booking);
|
||||
$this->denyUnlessCsrfValid($request, 'release-'.$booking->getId());
|
||||
|
||||
$date = $booking->getDate();
|
||||
$label = $booking->getResource()->getLabel();
|
||||
@@ -118,20 +161,109 @@ final class BookingController extends AbstractController
|
||||
return $this->redirectToRoute('app_booking');
|
||||
}
|
||||
|
||||
return $this->boardResponse($date);
|
||||
return $this->boardResponse($date, DashboardController::timeOf($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a series from today onwards. Past occurrences are history and stay put.
|
||||
*/
|
||||
#[Route('/serien/{id}/beenden', name: 'app_series_end', methods: ['POST'])]
|
||||
public function endSeries(Request $request, BookingSeries $series): Response
|
||||
{
|
||||
if ($series->getUser()->getId() !== $this->currentUser()->getId()) {
|
||||
throw $this->createAccessDeniedException('Fremde Serien können nicht beendet werden.');
|
||||
}
|
||||
|
||||
$this->denyUnlessCsrfValid($request, 'end-series-'.$series->getId());
|
||||
|
||||
$removed = $this->bookings->endSeries($series, new \DateTimeImmutable('today'));
|
||||
|
||||
$this->addFlash('success', \sprintf('Serie beendet, %d Termine entfernt.', $removed));
|
||||
|
||||
return $this->redirectToRoute('app_booking');
|
||||
}
|
||||
|
||||
private function createSeries(BookingRequest $data, Resource $resource): SeriesResult
|
||||
{
|
||||
\assert($data->date instanceof \DateTimeImmutable);
|
||||
\assert($data->repeat instanceof \App\Booking\RecurrenceRule);
|
||||
\assert($data->until instanceof \DateTimeImmutable);
|
||||
|
||||
return $this->bookings->bookSeries(new BookingSeries(
|
||||
$this->currentUser(),
|
||||
$resource,
|
||||
$data->repeat,
|
||||
$data->date,
|
||||
$data->until,
|
||||
$data->timeRange(),
|
||||
$data->weekdays,
|
||||
));
|
||||
}
|
||||
|
||||
private function flashSeriesResult(SeriesResult $result, Resource $resource): void
|
||||
{
|
||||
if ($result->isCompletelyBlocked()) {
|
||||
$this->addFlash('warning', \sprintf('%s ist an keinem Tag der Serie frei.', $resource->getLabel()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$message = \sprintf('%s: %d Termine gebucht.', $resource->getLabel(), $result->createdCount());
|
||||
|
||||
if ($result->skippedCount() > 0) {
|
||||
$message .= \sprintf(' %d Tage waren belegt (%s).', $result->skippedCount(), implode(', ', $result->skippedDates()));
|
||||
}
|
||||
|
||||
$this->addFlash($result->skippedCount() > 0 ? 'warning' : 'success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-renders the modal with an inline error. Symfony turns an invalid form into a 422 by
|
||||
* itself, which is exactly what htmx needs to swap it.
|
||||
*/
|
||||
private function invalidModal(FormInterface $form, Resource $resource, string $message, ?Booking $booking = null): Response
|
||||
{
|
||||
$form->addError(new FormError($message));
|
||||
|
||||
return $this->render(
|
||||
null === $booking ? 'booking/_modal_create.html.twig' : 'booking/_modal_edit.html.twig',
|
||||
['form' => $form, 'resource' => $resource, 'booking' => $booking],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole response is out-of-band board blocks. Callers pair it with hx-swap="none"
|
||||
* (or an outerHTML target that should vanish, as the modal does).
|
||||
*/
|
||||
private function boardResponse(\DateTimeImmutable $date): Response
|
||||
private function boardResponse(\DateTimeImmutable $date, TimeRange $time): Response
|
||||
{
|
||||
return $this->htmxOobResponse(
|
||||
'dashboard/index.html.twig',
|
||||
['calendar', 'floorplan', 'flashes'],
|
||||
$this->board->context($date, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', ['tag' => $date->format('Y-m-d')]),
|
||||
$this->board->context($date, $time, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($date, $time)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function timeParameters(TimeRange $time): array
|
||||
{
|
||||
return $time->isAllDay() ? [] : ['von' => $time->start(), 'bis' => $time->end()];
|
||||
}
|
||||
|
||||
private function denyUnlessOwn(Booking $booking): void
|
||||
{
|
||||
if (!$booking->belongsTo($this->currentUser())) {
|
||||
throw $this->createAccessDeniedException('Fremde Buchungen können nicht geändert werden.');
|
||||
}
|
||||
}
|
||||
|
||||
private function denyUnlessCsrfValid(Request $request, string $tokenId): void
|
||||
{
|
||||
if (!$this->isCsrfTokenValid($tokenId, $request->request->getString('_token'))) {
|
||||
throw $this->createAccessDeniedException('Ungültiges CSRF-Token.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Booking\BoardPresenter;
|
||||
use App\Booking\DateParam;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Htmx\HxTrait;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -13,7 +14,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* The booking board: a month calendar next to the floor plan for the selected day.
|
||||
* The booking board: a month calendar next to the floor plan for the selected day and span.
|
||||
*/
|
||||
final class DashboardController extends AbstractController
|
||||
{
|
||||
@@ -29,11 +30,12 @@ final class DashboardController extends AbstractController
|
||||
{
|
||||
$date = DateParam::parseOrToday($request->query->getString('tag'));
|
||||
|
||||
return $this->render('dashboard/index.html.twig', $this->board->context($date, $this->currentUser()));
|
||||
return $this->render('dashboard/index.html.twig', $this->board->context($date, self::timeOf($request), $this->currentUser()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Picking a day swaps the calendar and the floor plan in one response.
|
||||
* Picking a day -- or narrowing the span -- swaps the calendar and the floor plan in one
|
||||
* response.
|
||||
*
|
||||
* This is the whole reason htmxOobResponse exists: two regions, one round trip, no
|
||||
* client-side event choreography. Requests without the htmx header get a plain redirect
|
||||
@@ -43,16 +45,27 @@ final class DashboardController extends AbstractController
|
||||
public function day(Request $request, string $date): Response
|
||||
{
|
||||
$selected = DateParam::parseOrToday($date);
|
||||
$time = self::timeOf($request);
|
||||
|
||||
if (!$request->headers->has('HX-Request')) {
|
||||
return $this->redirectToRoute('app_dashboard', ['tag' => $selected->format('Y-m-d')]);
|
||||
return $this->redirectToRoute('app_dashboard', BoardPresenter::urlParameters($selected, $time));
|
||||
}
|
||||
|
||||
return $this->htmxOobResponse(
|
||||
'dashboard/index.html.twig',
|
||||
['calendar', 'floorplan'],
|
||||
$this->board->context($selected, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', ['tag' => $selected->format('Y-m-d')]),
|
||||
$this->board->context($selected, $time, $this->currentUser()),
|
||||
$this->generateUrl('app_dashboard', BoardPresenter::urlParameters($selected, $time)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A malformed or missing span falls back to the whole day rather than erroring: these are
|
||||
* hand-editable query parameters, not route requirements.
|
||||
*/
|
||||
public static function timeOf(Request $request): TimeRange
|
||||
{
|
||||
return TimeRange::parse($request->query->getString('von'), $request->query->getString('bis'))
|
||||
?? TimeRange::wholeDay();
|
||||
}
|
||||
}
|
||||
+81
-7
@@ -4,17 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* One person holding one resource for one whole day.
|
||||
* One person holding one resource for a span of one day.
|
||||
*
|
||||
* The unique constraint is the actual guard against double-booking -- checking first and then
|
||||
* inserting is racy, so callers insert and catch UniqueConstraintViolationException instead.
|
||||
* There is deliberately NO unique constraint on (resource, date) any more: since a booking
|
||||
* covers a time range, the thing to prevent is an *overlap*, which no unique index can express
|
||||
* and which MariaDB has no exclusion constraint for either. BookingManager serialises instead --
|
||||
* it locks the resource row FOR UPDATE and checks for overlaps inside that transaction. The
|
||||
* index below is what makes that check cheap.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: BookingRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_booking_resource_date', columns: ['resource_id', 'date'])]
|
||||
#[ORM\Index(name: 'idx_booking_resource_date', columns: ['resource_id', 'date'])]
|
||||
#[ORM\Index(name: 'idx_booking_user_date', columns: ['user_id', 'date'])]
|
||||
class Booking
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -33,15 +38,45 @@ class Booking
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $date;
|
||||
|
||||
/** Minutes from midnight, end-exclusive. All-day is 0 to 1440. */
|
||||
#[ORM\Column]
|
||||
private int $startMinute;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $endMinute;
|
||||
|
||||
/**
|
||||
* The series this occurrence was materialised from, if any. SET NULL rather than CASCADE so
|
||||
* that dropping a series never silently takes bookings with it -- ending a series removes
|
||||
* its future occurrences explicitly, in BookingManager.
|
||||
*/
|
||||
#[ORM\ManyToOne(inversedBy: 'bookings')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?BookingSeries $series = null;
|
||||
|
||||
/** True once this occurrence's times were changed away from the series' own. */
|
||||
#[ORM\Column]
|
||||
private bool $overridden = false;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(User $user, Resource $resource, \DateTimeImmutable $date)
|
||||
{
|
||||
public function __construct(
|
||||
User $user,
|
||||
Resource $resource,
|
||||
\DateTimeImmutable $date,
|
||||
?TimeRange $time = null,
|
||||
?BookingSeries $series = null,
|
||||
) {
|
||||
$time ??= TimeRange::wholeDay();
|
||||
|
||||
$this->user = $user;
|
||||
$this->resource = $resource;
|
||||
// Normalised to midnight so the unique constraint cannot be sidestepped by a time part.
|
||||
// Normalised to midnight so a stray time component cannot shift which day this belongs to.
|
||||
$this->date = $date->setTime(0, 0);
|
||||
$this->startMinute = $time->startMinute;
|
||||
$this->endMinute = $time->endMinute;
|
||||
$this->series = $series;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
@@ -65,6 +100,40 @@ class Booking
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function getTimeRange(): TimeRange
|
||||
{
|
||||
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changing the times of an occurrence marks it as an override, so the UI can show that it
|
||||
* no longer matches its series.
|
||||
*/
|
||||
public function changeTime(TimeRange $time): void
|
||||
{
|
||||
$this->startMinute = $time->startMinute;
|
||||
$this->endMinute = $time->endMinute;
|
||||
|
||||
if (null !== $this->series) {
|
||||
$this->overridden = !$this->series->getTimeRange()->equals($time);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSeries(): ?BookingSeries
|
||||
{
|
||||
return $this->series;
|
||||
}
|
||||
|
||||
public function isPartOfSeries(): bool
|
||||
{
|
||||
return null !== $this->series;
|
||||
}
|
||||
|
||||
public function isOverridden(): bool
|
||||
{
|
||||
return $this->overridden;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
@@ -74,4 +143,9 @@ class Booking
|
||||
{
|
||||
return $this->user->getId() === $user->getId();
|
||||
}
|
||||
|
||||
public function overlaps(TimeRange $time): bool
|
||||
{
|
||||
return $this->getTimeRange()->overlaps($time);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Repository\BookingSeriesRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* A repeating reservation.
|
||||
*
|
||||
* The series is a description, not a source of truth for availability: creating one writes real
|
||||
* Booking rows for every occurrence up to endDate. That is what keeps the conflict check
|
||||
* database-backed -- a rule that is only expanded at render time is invisible to every other
|
||||
* booking attempt.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: BookingSeriesRepository::class)]
|
||||
class BookingSeries
|
||||
{
|
||||
/** A series may not run further out than this; desks are not booked years ahead. */
|
||||
public const MAX_MONTHS = 12;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private User $user;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'RESTRICT')]
|
||||
private Resource $resource;
|
||||
|
||||
#[ORM\Column(enumType: RecurrenceRule::class)]
|
||||
private RecurrenceRule $rule;
|
||||
|
||||
/**
|
||||
* ISO weekdays (1 = Monday) the series falls on. Empty for a DAILY rule, which means
|
||||
* every calendar day.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private array $weekdays = [];
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $startDate;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
private \DateTimeImmutable $endDate;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $startMinute;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $endMinute;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
/** @var Collection<int, Booking> */
|
||||
#[ORM\OneToMany(targetEntity: Booking::class, mappedBy: 'series')]
|
||||
private Collection $bookings;
|
||||
|
||||
/**
|
||||
* @param list<int> $weekdays
|
||||
*/
|
||||
public function __construct(
|
||||
User $user,
|
||||
Resource $resource,
|
||||
RecurrenceRule $rule,
|
||||
\DateTimeImmutable $startDate,
|
||||
\DateTimeImmutable $endDate,
|
||||
TimeRange $time,
|
||||
array $weekdays = [],
|
||||
) {
|
||||
$this->user = $user;
|
||||
$this->resource = $resource;
|
||||
$this->rule = $rule;
|
||||
$this->startDate = $startDate->setTime(0, 0);
|
||||
$this->endDate = $endDate->setTime(0, 0);
|
||||
$this->startMinute = $time->startMinute;
|
||||
$this->endMinute = $time->endMinute;
|
||||
$this->weekdays = RecurrenceRule::WEEKLY === $rule ? array_values(array_unique($weekdays)) : [];
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
$this->bookings = new ArrayCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every day this series falls on, in order.
|
||||
*
|
||||
* @return list<\DateTimeImmutable>
|
||||
*/
|
||||
public function occurrences(): array
|
||||
{
|
||||
$dates = [];
|
||||
|
||||
for ($day = $this->startDate; $day <= $this->endDate; $day = $day->modify('+1 day')) {
|
||||
if (RecurrenceRule::WEEKLY === $this->rule && !\in_array((int) $day->format('N'), $this->weekdays, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dates[] = $day;
|
||||
}
|
||||
|
||||
return $dates;
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getResource(): Resource
|
||||
{
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public function getRule(): RecurrenceRule
|
||||
{
|
||||
return $this->rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function getWeekdays(): array
|
||||
{
|
||||
return $this->weekdays;
|
||||
}
|
||||
|
||||
public function getStartDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->startDate;
|
||||
}
|
||||
|
||||
public function getEndDate(): \DateTimeImmutable
|
||||
{
|
||||
return $this->endDate;
|
||||
}
|
||||
|
||||
public function setEndDate(\DateTimeImmutable $endDate): void
|
||||
{
|
||||
$this->endDate = $endDate->setTime(0, 0);
|
||||
}
|
||||
|
||||
public function getTimeRange(): TimeRange
|
||||
{
|
||||
return TimeRange::fromMinutes($this->startMinute, $this->endMinute);
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Booking>
|
||||
*/
|
||||
public function getBookings(): Collection
|
||||
{
|
||||
return $this->bookings;
|
||||
}
|
||||
|
||||
public function describe(): string
|
||||
{
|
||||
$description = $this->rule->label();
|
||||
|
||||
if (RecurrenceRule::WEEKLY === $this->rule && [] !== $this->weekdays) {
|
||||
$names = ['', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
// Sort the numbers, not the names -- alphabetical would read "Di, Do, Fr, Mi, Mo".
|
||||
$weekdays = $this->weekdays;
|
||||
sort($weekdays);
|
||||
$description .= ' ('.implode(', ', array_map(static fn (int $day): string => $names[$day], $weekdays)).')';
|
||||
}
|
||||
|
||||
return $description.', '.$this->getTimeRange();
|
||||
}
|
||||
}
|
||||
+105
-7
@@ -4,18 +4,116 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\BookingSeries;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
/**
|
||||
* What the booking modal edits. The resource is fixed by the URL -- only the day is adjustable,
|
||||
* because the modal is opened by clicking a resource on the floor plan.
|
||||
* What the booking modal edits. The resource is fixed by the URL -- it is chosen by clicking a
|
||||
* shape on the floor plan, not in the form.
|
||||
*/
|
||||
final class BookingRequest
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
|
||||
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
|
||||
public ?\DateTimeImmutable $date = null,
|
||||
) {
|
||||
#[Assert\NotNull(message: 'Bitte einen Tag wählen.')]
|
||||
#[Assert\GreaterThanOrEqual('today', message: 'Für vergangene Tage kann nicht gebucht werden.')]
|
||||
public ?\DateTimeImmutable $date = null;
|
||||
|
||||
public bool $allDay = true;
|
||||
|
||||
public ?\DateTimeImmutable $startTime = null;
|
||||
|
||||
public ?\DateTimeImmutable $endTime = null;
|
||||
|
||||
public ?RecurrenceRule $repeat = null;
|
||||
|
||||
/** @var list<int> ISO weekdays, 1 = Monday */
|
||||
public array $weekdays = [];
|
||||
|
||||
public ?\DateTimeImmutable $until = null;
|
||||
|
||||
public function __construct(?\DateTimeImmutable $date = null, ?TimeRange $time = null)
|
||||
{
|
||||
$this->date = $date;
|
||||
|
||||
if (null !== $time && !$time->isAllDay()) {
|
||||
$this->allDay = false;
|
||||
$this->startTime = self::timeOf($time->startMinute);
|
||||
$this->endTime = self::timeOf($time->endMinute);
|
||||
}
|
||||
}
|
||||
|
||||
public function timeRange(): TimeRange
|
||||
{
|
||||
if ($this->allDay || null === $this->startTime || null === $this->endTime) {
|
||||
return TimeRange::wholeDay();
|
||||
}
|
||||
|
||||
return TimeRange::fromTimes($this->startTime, $this->endTime);
|
||||
}
|
||||
|
||||
public function repeats(): bool
|
||||
{
|
||||
return null !== $this->repeat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-field rules. These live here rather than in the form type so they hold for any caller,
|
||||
* and so the messages land on the field the user has to fix.
|
||||
*/
|
||||
#[Assert\Callback]
|
||||
public function validate(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (!$this->allDay) {
|
||||
foreach (['startTime' => $this->startTime, 'endTime' => $this->endTime] as $field => $value) {
|
||||
if (null === $value) {
|
||||
$context->buildViolation('Bitte eine Uhrzeit angeben.')->atPath($field)->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $this->startTime && null !== $this->endTime
|
||||
&& self::minutesOf($this->endTime) <= self::minutesOf($this->startTime)) {
|
||||
$context->buildViolation('Das Ende muss nach dem Beginn liegen.')->atPath('endTime')->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->repeats()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (RecurrenceRule::WEEKLY === $this->repeat && [] === $this->weekdays) {
|
||||
$context->buildViolation('Bitte mindestens einen Wochentag wählen.')->atPath('weekdays')->addViolation();
|
||||
}
|
||||
|
||||
if (null === $this->until) {
|
||||
$context->buildViolation('Bitte ein Enddatum für die Serie angeben.')->atPath('until')->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $this->date && $this->until < $this->date) {
|
||||
$context->buildViolation('Das Serienende muss nach dem Beginn liegen.')->atPath('until')->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latest = ($this->date ?? new \DateTimeImmutable('today'))->modify(\sprintf('+%d months', BookingSeries::MAX_MONTHS));
|
||||
if ($this->until > $latest) {
|
||||
$context->buildViolation(\sprintf('Eine Serie darf höchstens %d Monate laufen.', BookingSeries::MAX_MONTHS))
|
||||
->atPath('until')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
private static function timeOf(int $minutes): \DateTimeImmutable
|
||||
{
|
||||
// 24:00 has no representation as a time-of-day, so an all-day end never reaches here.
|
||||
return (new \DateTimeImmutable('today'))->setTime(intdiv($minutes, 60), $minutes % 60);
|
||||
}
|
||||
|
||||
private static function minutesOf(\DateTimeInterface $time): int
|
||||
{
|
||||
return (int) $time->format('G') * 60 + (int) $time->format('i');
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Booking\RecurrenceRule;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TimeType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
@@ -13,12 +18,48 @@ final class BookingType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder->add('date', DateType::class, [
|
||||
'label' => 'Tag',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'html5' => true,
|
||||
]);
|
||||
$builder
|
||||
->add('date', DateType::class, [
|
||||
'label' => 'Tag',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
])
|
||||
->add('allDay', CheckboxType::class, [
|
||||
'label' => 'Ganzer Tag',
|
||||
'required' => false,
|
||||
])
|
||||
->add('startTime', TimeType::class, [
|
||||
'label' => 'Von',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'required' => false,
|
||||
])
|
||||
->add('endTime', TimeType::class, [
|
||||
'label' => 'Bis',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'required' => false,
|
||||
])
|
||||
->add('repeat', EnumType::class, [
|
||||
'label' => 'Wiederholung',
|
||||
'class' => RecurrenceRule::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'Einmalig',
|
||||
'choice_label' => static fn (RecurrenceRule $rule): string => $rule->label(),
|
||||
])
|
||||
->add('weekdays', ChoiceType::class, [
|
||||
'label' => 'Wochentage',
|
||||
'choices' => ['Mo' => 1, 'Di' => 2, 'Mi' => 3, 'Do' => 4, 'Fr' => 5, 'Sa' => 6, 'So' => 7],
|
||||
'multiple' => true,
|
||||
'expanded' => true,
|
||||
'required' => false,
|
||||
])
|
||||
->add('until', DateType::class, [
|
||||
'label' => 'Serie bis',
|
||||
'widget' => 'single_text',
|
||||
'input' => 'datetime_immutable',
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -4,10 +4,14 @@ 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 Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
@@ -21,24 +25,80 @@ class BookingRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything booked on one day, for colouring the floor plan.
|
||||
* Everything on one day that overlaps the given span, for colouring the floor plan.
|
||||
*
|
||||
* Team view is in scope, so the booking holder's name is shown on occupied resources --
|
||||
* hence the joins are eager, or the plan would fire a query per drawn element.
|
||||
*
|
||||
* @return list<Booking>
|
||||
*/
|
||||
public function findForDate(\DateTimeImmutable $date): array
|
||||
public function findForDate(\DateTimeImmutable $date, ?TimeRange $within = null): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->addSelect('u', 'r')
|
||||
->join('b.user', 'u')
|
||||
->join('b.resource', 'r')
|
||||
->andWhere('b.date = :date')
|
||||
->setParameter('date', $date->setTime(0, 0))
|
||||
->orderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->addOrderBy('b.startMinute', 'ASC');
|
||||
|
||||
if (null !== $within) {
|
||||
self::restrictToOverlap($qb, $within);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* The availability check behind every booking. Only meaningful while the resource row is
|
||||
* locked -- see BookingManager, which is the only caller.
|
||||
*
|
||||
* @return list<Booking>
|
||||
*/
|
||||
public function findOverlapping(Resource $resource, \DateTimeImmutable $date, TimeRange $time): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->andWhere('b.resource = :resource')
|
||||
->andWhere('b.date = :date')
|
||||
->setParameter('resource', $resource)
|
||||
->setParameter('date', $date->setTime(0, 0));
|
||||
|
||||
self::restrictToOverlap($qb, $time);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* One person may not hold two resources of the same kind at the same time -- but they may
|
||||
* well hold a desk in the morning and a different one in the afternoon, so this is an
|
||||
* overlap test rather than a per-day test.
|
||||
*/
|
||||
public function findOverlappingForUser(
|
||||
User $user,
|
||||
\DateTimeImmutable $date,
|
||||
TimeRange $time,
|
||||
ResourceType $type,
|
||||
?Booking $ignore = null,
|
||||
): ?Booking {
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->addSelect('r')
|
||||
->join('b.resource', 'r')
|
||||
->andWhere('b.user = :user')
|
||||
->andWhere('b.date = :date')
|
||||
->andWhere('r.type = :type')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('date', $date->setTime(0, 0))
|
||||
->setParameter('type', $type)
|
||||
->setMaxResults(1);
|
||||
|
||||
self::restrictToOverlap($qb, $time);
|
||||
|
||||
if (null !== $ignore && null !== $ignore->getId()) {
|
||||
$qb->andWhere('b.id != :ignore')->setParameter('ignore', $ignore->getId());
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,14 +109,16 @@ class BookingRepository extends ServiceEntityRepository
|
||||
public function findForUserInRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->addSelect('r')
|
||||
->addSelect('r', 's')
|
||||
->join('b.resource', 'r')
|
||||
->leftJoin('b.series', 's')
|
||||
->andWhere('b.user = :user')
|
||||
->andWhere('b.date BETWEEN :from AND :to')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('from', $from->setTime(0, 0))
|
||||
->setParameter('to', $to->setTime(0, 0))
|
||||
->orderBy('b.date', 'ASC')
|
||||
->addOrderBy('b.startMinute', 'ASC')
|
||||
->addOrderBy('r.label', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
@@ -64,50 +126,58 @@ class BookingRepository extends ServiceEntityRepository
|
||||
|
||||
/**
|
||||
* Bookings per day across a window, keyed by Y-m-d -- what the calendar grid renders as
|
||||
* occupancy, and the same query team view reads.
|
||||
* occupancy. Counts only what overlaps the span the board is currently showing, so the
|
||||
* number in a day cell always means the same thing as the colours on the plan.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array
|
||||
public function countPerDateInRange(\DateTimeImmutable $from, \DateTimeImmutable $to, ?TimeRange $within = null): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('b')
|
||||
$qb = $this->createQueryBuilder('b')
|
||||
->select('b.date AS date', 'COUNT(b.id) AS total')
|
||||
->andWhere('b.date BETWEEN :from AND :to')
|
||||
->setParameter('from', $from->setTime(0, 0))
|
||||
->setParameter('to', $to->setTime(0, 0))
|
||||
->groupBy('b.date')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
->groupBy('b.date');
|
||||
|
||||
if (null !== $within) {
|
||||
self::restrictToOverlap($qb, $within);
|
||||
}
|
||||
|
||||
$counts = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach ($qb->getQuery()->getResult() as $row) {
|
||||
$counts[$row['date']->format('Y-m-d')] = (int) $row['total'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
public function findOneForUserAndDate(User $user, \DateTimeImmutable $date): ?Booking
|
||||
/**
|
||||
* The occurrences an "end this series" would remove.
|
||||
*
|
||||
* @return list<Booking>
|
||||
*/
|
||||
public function findForSeriesFrom(BookingSeries $series, \DateTimeImmutable $from): array
|
||||
{
|
||||
return $this->findOneBy(['user' => $user, 'date' => $date->setTime(0, 0)]);
|
||||
return $this->createQueryBuilder('b')
|
||||
->andWhere('b.series = :series')
|
||||
->andWhere('b.date >= :from')
|
||||
->setParameter('series', $series)
|
||||
->setParameter('from', $from->setTime(0, 0))
|
||||
->orderBy('b.date', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* One person may hold at most one desk and one parking spot per day, so this is what the
|
||||
* booking path checks before inserting.
|
||||
* Half-open overlap, the same test as TimeRange::overlaps(): two spans collide unless one
|
||||
* ends at or before the other begins. Written once here so the SQL and the PHP cannot drift.
|
||||
*/
|
||||
public function findOneForUserDateAndType(User $user, \DateTimeImmutable $date, ResourceType $type): ?Booking
|
||||
private static function restrictToOverlap(QueryBuilder $qb, TimeRange $time): void
|
||||
{
|
||||
return $this->createQueryBuilder('b')
|
||||
->addSelect('r')
|
||||
->join('b.resource', 'r')
|
||||
->andWhere('b.user = :user')
|
||||
->andWhere('b.date = :date')
|
||||
->andWhere('r.type = :type')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('date', $date->setTime(0, 0))
|
||||
->setParameter('type', $type)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
$qb->andWhere('b.startMinute < :rangeEnd')
|
||||
->andWhere(':rangeStart < b.endMinute')
|
||||
->setParameter('rangeStart', $time->startMinute)
|
||||
->setParameter('rangeEnd', $time->endMinute);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\BookingSeries;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<BookingSeries>
|
||||
*/
|
||||
class BookingSeriesRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, BookingSeries::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<BookingSeries>
|
||||
*/
|
||||
public function findActiveForUser(User $user, \DateTimeImmutable $on): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->addSelect('r')
|
||||
->join('s.resource', 'r')
|
||||
->andWhere('s.user = :user')
|
||||
->andWhere('s.endDate >= :on')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('on', $on->setTime(0, 0))
|
||||
->orderBy('s.startDate', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{#
|
||||
The shared body of both booking modals. Which fields exist is decided by the controller
|
||||
(the edit modal removes date and the recurrence fields), so everything here is guarded with
|
||||
`is defined` rather than duplicated into two templates.
|
||||
|
||||
Show/hide is Stimulus, not htmx: toggling a fieldset needs no server round trip, and the
|
||||
house rule keeps purely client-side behaviour out of htmx.
|
||||
#}
|
||||
<div {{ stimulus_controller('bookingform') }}>
|
||||
{% if form.date is defined %}
|
||||
{{ form_row(form.date) }}
|
||||
{% endif %}
|
||||
|
||||
<div class="pt-4">
|
||||
{{ form_row(form.allDay, { attr: { 'data-action': 'bookingform#toggleTimes' } }) }}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 pt-2 {{ form.allDay.vars.data ? 'hidden' : '' }}"
|
||||
{{ stimulus_target('bookingform', 'times') }}>
|
||||
{{ form_row(form.startTime) }}
|
||||
{{ form_row(form.endTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.repeat is defined %}
|
||||
<div class="pt-4 border-t border-gray-200 mt-4">
|
||||
{{ form_row(form.repeat, { attr: { 'data-action': 'bookingform#toggleRepeat' } }) }}
|
||||
|
||||
<div class="pt-2 {{ form.repeat.vars.data is null ? 'hidden' : '' }}"
|
||||
{{ stimulus_target('bookingform', 'repeat') }}>
|
||||
<div class="{{ form.repeat.vars.data is null or form.repeat.vars.data.value != 'weekly' ? 'hidden' : '' }}"
|
||||
{{ stimulus_target('bookingform', 'weekdays') }}>
|
||||
{{ form_row(form.weekdays, { row_attr: { class: 'pb-2' } }) }}
|
||||
</div>
|
||||
{{ form_row(form.until) }}
|
||||
<p class="pt-1 text-xs text-gray-500">
|
||||
Belegte Tage werden übersprungen und danach aufgelistet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
{{ resource.type.label }} · {{ resource.label }}
|
||||
</p>
|
||||
|
||||
{{ form_row(form.date) }}
|
||||
{% include 'booking/_fields.html.twig' %}
|
||||
|
||||
<div class="flex justify-end gap-2 pt-6">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{#
|
||||
Editing one occurrence. For a booking that belongs to a series this is the "override": only
|
||||
this day moves, the rest of the series is untouched and the occurrence is flagged as
|
||||
differing from it.
|
||||
|
||||
Releasing lives here too rather than on the floor plan, so the SVG needs no confirm dialogs
|
||||
or inline CSRF tokens -- clicking your own desk always just opens this.
|
||||
#}
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}{{ resource.label }} ändern{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, { attr: {
|
||||
'hx-post': form.vars.action,
|
||||
'hx-target': '#htmx-modal',
|
||||
'hx-swap': 'outerHTML',
|
||||
} }) }}
|
||||
{{ form_errors(form) }}
|
||||
|
||||
<p class="pb-4 text-sm text-gray-500">
|
||||
{{ resource.type.label }} · {{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
|
||||
</p>
|
||||
|
||||
{% if booking.partOfSeries %}
|
||||
<div class="mb-4 rounded-md bg-gray-50 px-4 py-3 text-sm">
|
||||
Teil einer Serie: {{ booking.series.describe }}.
|
||||
<span class="text-gray-500">Eine Änderung hier gilt nur für diesen Tag.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'booking/_fields.html.twig' %}
|
||||
|
||||
<div class="flex flex-wrap justify-between gap-2 pt-6">
|
||||
{# type="button" with its own hx-post: htmx issues the release request directly, so
|
||||
this never submits the surrounding edit form and no nested <form> is needed. #}
|
||||
<button type="button" class="button button--small button--warning"
|
||||
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
|
||||
hx-confirm="{{ resource.label }} am {{ booking.date|date('d.m.Y') }} freigeben?"
|
||||
hx-target="#htmx-modal"
|
||||
hx-swap="outerHTML">
|
||||
Freigeben
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="button button--primary">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -6,20 +6,51 @@
|
||||
<h1>Meine Buchungen</h1>
|
||||
<p class="subline">Alles, was ab heute für dich reserviert ist.</p>
|
||||
|
||||
{% if series is not empty %}
|
||||
<h2 class="pt-8">Serien</h2>
|
||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||
{% for entry in series %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">{{ entry.resource.label }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ entry.describe }} · bis {{ entry.endDate|format_datetime(pattern: 'd. MMMM y') }}
|
||||
</div>
|
||||
</div>
|
||||
{# Ends the series from today on; past occurrences are history and stay put. #}
|
||||
<form method="post" action="{{ path('app_series_end', { id: entry.id }) }}"
|
||||
onsubmit="return confirm('Serie ab heute beenden? Alle künftigen Termine werden entfernt.')">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('end-series-' ~ entry.id) }}">
|
||||
<button type="submit" class="button button--small button--neutral">Serie beenden</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="pt-8">Termine</h2>
|
||||
|
||||
{% if bookings is empty %}
|
||||
<p class="mt-8 text-sm text-gray-500">
|
||||
<p class="text-sm text-gray-500">
|
||||
Noch nichts gebucht. <a class="underline" href="{{ path('app_dashboard') }}">Zum Raumplan</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="mt-8 divide-y divide-gray-200 border-y border-gray-200">
|
||||
<ul class="divide-y divide-gray-200 border-y border-gray-200">
|
||||
{% for booking in bookings %}
|
||||
<li class="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<div class="font-semibold">
|
||||
{{ booking.date|format_datetime(pattern: 'EEEE, d. MMMM y') }}
|
||||
<span class="font-normal text-gray-500">· {{ booking.timeRange }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ booking.resource.type.label }} · {{ booking.resource.label }}
|
||||
{% if booking.partOfSeries %}
|
||||
<span class="badge bg-gray-100 text-gray-600 ml-1">Serie</span>
|
||||
{% endif %}
|
||||
{% if booking.overridden %}
|
||||
<span class="badge bg-amber-100 text-amber-800 ml-1">abweichend</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
{# Server-rendered month grid. No calendar library: prev/next and day selection are plain hx-get
|
||||
requests whose response carries nothing but out-of-band blocks, hence hx-swap="none". #}
|
||||
{% set weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] %}
|
||||
{# Every link keeps the board's span, so changing the day never silently widens it back to all-day. #}
|
||||
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
|
||||
|
||||
<div class="rounded-md border border-gray-200 p-4">
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<button type="button"
|
||||
class="p-1 rounded-md hover:bg-gray-100"
|
||||
aria-label="Voriger Monat"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }) }}"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('-1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-swap="none">
|
||||
{{ ux_icon('lucide:chevron-left', { class: 'w-5 h-5' }) }}
|
||||
</button>
|
||||
@@ -17,7 +19,7 @@
|
||||
<button type="button"
|
||||
class="p-1 rounded-md hover:bg-gray-100"
|
||||
aria-label="Nächster Monat"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }) }}"
|
||||
hx-get="{{ path('app_dashboard_day', { date: month_start|date_modify('+1 month')|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-swap="none">
|
||||
{{ ux_icon('lucide:chevron-right', { class: 'w-5 h-5' }) }}
|
||||
</button>
|
||||
@@ -47,7 +49,7 @@
|
||||
<button type="button"
|
||||
class="{{ classes }}"
|
||||
aria-pressed="{{ day.is_selected ? 'true' : 'false' }}"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }) }}"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day.date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-swap="none">
|
||||
<span>{{ day.date|date('j') }}</span>
|
||||
{# Team view: the occupancy count is the whole point of showing the month. #}
|
||||
@@ -66,6 +68,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-primary"></span> eigene Buchung
|
||||
</div>
|
||||
<div>Zahl = belegte Plätze an dem Tag</div>
|
||||
<div>Zahl = belegte Plätze{{ selected_time.allDay ? ' an dem Tag' : ' zwischen ' ~ selected_time.start ~ ' und ' ~ selected_time.end }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,8 +3,14 @@
|
||||
is the key that ties a database row to a shape here. A resource whose id is missing from the
|
||||
map is simply not drawn (and vice versa), which is why app:seed and this map have to agree.
|
||||
|
||||
Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day,
|
||||
so free/booked/yours is decided in Twig.
|
||||
Colouring needs no JavaScript at all: the SVG is re-rendered server side for the selected day
|
||||
and span, so free/belegt/deine is decided in Twig. Note "free" is always relative to the
|
||||
span in the header -- a desk booked 09:00-13:00 is free for an afternoon board and belegt for
|
||||
an all-day one.
|
||||
|
||||
Clicking a shape always opens a modal: the create modal when it is free, the edit modal when
|
||||
it is yours (which is where changing the times and releasing it live). Nothing else is
|
||||
clickable, so the SVG carries no confirm dialogs or inline tokens.
|
||||
#}
|
||||
{% set desk_plan = {
|
||||
'desk-01': { x: 50, y: 70 }, 'desk-02': { x: 190, y: 70 }, 'desk-03': { x: 330, y: 70 },
|
||||
@@ -16,47 +22,65 @@
|
||||
'parking-01': { x: 540, y: 60 }, 'parking-02': { x: 540, y: 125 }, 'parking-03': { x: 540, y: 190 },
|
||||
'parking-04': { x: 540, y: 255 }, 'parking-05': { x: 540, y: 320 }, 'parking-06': { x: 540, y: 385 }
|
||||
} %}
|
||||
{% set time_params = selected_time.allDay ? {} : { von: selected_time.start, bis: selected_time.end } %}
|
||||
|
||||
{% macro spot(resource, geometry, width, height, bookings, selected_date, is_past, time_params) %}
|
||||
{% set own = bookings|filter(b => b.user.id == app.user.id)|first|default(null) %}
|
||||
{% set mine = own is not null %}
|
||||
|
||||
{% macro spot(resource, geometry, width, height, booking, selected_date, is_past) %}
|
||||
{% set mine = booking is not null and booking.user.id == app.user.id %}
|
||||
{% if mine %}
|
||||
{% set fill = 'fill-primary' %}{% set stroke = 'stroke-primary-dark' %}{% set text = 'fill-white' %}
|
||||
{% elseif booking is not null %}
|
||||
{% elseif bookings is not empty %}
|
||||
{% set fill = 'fill-gray-200' %}{% set stroke = 'stroke-gray-400' %}{% set text = 'fill-gray-600' %}
|
||||
{% else %}
|
||||
{% set fill = 'fill-emerald-50' %}{% set stroke = 'stroke-emerald-500' %}{% set text = 'fill-emerald-900' %}
|
||||
{% endif %}
|
||||
|
||||
{% set interactive = not is_past and (mine or booking is null) %}
|
||||
{% set interactive = not is_past and (mine or bookings is empty) %}
|
||||
|
||||
{# What the second line of the shape says. Several bookings can overlap the board's span once
|
||||
part-day bookings exist, so the shape counts them and the tooltip spells them out. #}
|
||||
{% if mine %}
|
||||
{% set caption = own.timeRange.allDay ? 'du' : 'du ' ~ own.timeRange %}
|
||||
{% elseif bookings|length > 1 %}
|
||||
{% set caption = bookings|length ~ ' Buchungen' %}
|
||||
{% elseif bookings is not empty %}
|
||||
{% set caption = bookings|first.user.name %}
|
||||
{% else %}
|
||||
{% set caption = null %}
|
||||
{% endif %}
|
||||
|
||||
<g class="{{ interactive ? 'cursor-pointer' : 'cursor-default' }}"
|
||||
{%- if interactive and mine %}
|
||||
hx-post="{{ path('app_booking_release', { id: booking.id }) }}"
|
||||
hx-vals='{{ { _token: csrf_token('release-' ~ booking.id) }|json_encode }}'
|
||||
hx-confirm="{{ resource.label }} wieder freigeben?"
|
||||
hx-swap="none"
|
||||
hx-get="{{ path('app_booking_edit', { id: own.id }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- elseif interactive %}
|
||||
{# The modal is appended to the end of <body>; closing it is Stimulus, not htmx. #}
|
||||
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }) }}"
|
||||
hx-get="{{ path('app_booking_new', { id: resource.id, date: selected_date|date('Y-m-d') }|merge(time_params)) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{%- endif %}>
|
||||
<title>
|
||||
{{- resource.label }}{% if booking %} — {{ mine ? 'von dir gebucht' : booking.user.name }}{% else %} — frei{% endif -%}
|
||||
{{- resource.label }}
|
||||
{%- if bookings is empty %} — frei
|
||||
{%- else %}
|
||||
{%- for booking in bookings %} — {{ booking.user.id == app.user.id ? 'du' : booking.user.name }} {{ booking.timeRange }}{% endfor %}
|
||||
{%- endif -%}
|
||||
</title>
|
||||
<rect x="{{ geometry.x }}" y="{{ geometry.y }}" width="{{ width }}" height="{{ height }}" rx="6"
|
||||
class="{{ fill }} {{ stroke }}" stroke-width="2"></rect>
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (booking ? height / 2 - 4 : height / 2 + 4) }}"
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + (caption ? height / 2 - 4 : height / 2 + 4) }}"
|
||||
text-anchor="middle" class="{{ text }} text-xs font-semibold">{{ resource.label }}</text>
|
||||
{% if booking %}
|
||||
{% if caption %}
|
||||
<text x="{{ geometry.x + width / 2 }}" y="{{ geometry.y + height / 2 + 12 }}"
|
||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ mine ? 'du' : booking.user.name }}</text>
|
||||
text-anchor="middle" class="{{ text }} text-[10px]">{{ caption }}</text>
|
||||
{% endif %}
|
||||
</g>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="rounded-md border border-gray-200 p-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2 pb-2">
|
||||
<h2 class="!pb-0">{{ selected_date|format_datetime(pattern: 'EEEE, d. MMMM y') }}</h2>
|
||||
<div class="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded-sm bg-emerald-50 border-2 border-emerald-500"></span> frei</span>
|
||||
@@ -65,6 +89,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'dashboard/_timerange.html.twig' %}
|
||||
|
||||
{% if is_past %}
|
||||
<p class="pb-4 text-sm text-amber-700">Vergangener Tag — hier lässt sich nichts mehr buchen.</p>
|
||||
{% endif %}
|
||||
@@ -79,14 +105,14 @@
|
||||
{% for resource in desks %}
|
||||
{% if resource.svgElementId and desk_plan[resource.svgElementId] is defined %}
|
||||
{{ _self.spot(resource, desk_plan[resource.svgElementId], 120, 70,
|
||||
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
|
||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for resource in parking %}
|
||||
{% if resource.svgElementId and parking_plan[resource.svgElementId] is defined %}
|
||||
{{ _self.spot(resource, parking_plan[resource.svgElementId], 230, 50,
|
||||
bookings_by_resource[resource.id]|default(null), selected_date, is_past) }}
|
||||
bookings_by_resource[resource.id]|default([]), selected_date, is_past, time_params) }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{#
|
||||
The span the whole board is relative to. Free/belegt has no answer without it once bookings
|
||||
can cover part of a day, so this drives the plan colours AND the calendar occupancy counts.
|
||||
|
||||
Both fields hit the same fragment endpoint as the calendar does, so narrowing the span is the
|
||||
same single round trip as changing the day. hx-include pulls in the sibling field, because
|
||||
a span needs both ends to mean anything.
|
||||
#}
|
||||
{# An all-day board leaves both fields empty: 24:00 has no representation in <input type="time">,
|
||||
and an empty pair is exactly what TimeRange::parse() reads back as "the whole day". #}
|
||||
{% set day = selected_date|date('Y-m-d') %}
|
||||
|
||||
<div class="flex flex-wrap items-end gap-3 pb-4" id="board-timerange">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 pb-1" for="board-von">Von</label>
|
||||
<input type="time" id="board-von" name="von" value="{{ selected_time.allDay ? '' : selected_time.start }}" step="900"
|
||||
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
||||
hx-include="#board-bis"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 pb-1" for="board-bis">Bis</label>
|
||||
<input type="time" id="board-bis" name="bis" value="{{ selected_time.allDay ? '' : selected_time.end }}" step="900"
|
||||
class="rounded-md border-0 py-1.5 text-sm text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
||||
hx-include="#board-von"
|
||||
hx-trigger="change"
|
||||
hx-swap="none">
|
||||
</div>
|
||||
|
||||
{% if not selected_time.allDay %}
|
||||
<button type="button" class="button button--small button--neutral"
|
||||
hx-get="{{ path('app_dashboard_day', { date: day }) }}"
|
||||
hx-swap="none">
|
||||
Ganzer Tag
|
||||
</button>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500 pb-2">ganzer Tag</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<h1>Buchen</h1>
|
||||
<p class="subline">Tag im Kalender wählen, dann einen freien Platz im Raumplan anklicken.</p>
|
||||
<p class="subline">Tag und Zeitraum wählen, dann einen freien Platz im Raumplan anklicken.</p>
|
||||
|
||||
<div class="mt-8 grid gap-8 xl:grid-cols-3">
|
||||
<div class="xl:col-span-1">
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Booking;
|
||||
|
||||
use App\Booking\AlreadyHoldingResourceException;
|
||||
use App\Booking\BookingManager;
|
||||
use App\Booking\RecurrenceRule;
|
||||
use App\Booking\ResourceAlreadyBookedException;
|
||||
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 Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The double-booking guard now lives here rather than in a unique index, so this is where it is
|
||||
* tested. BookingManager opens its own transactions (it locks the resource row FOR UPDATE), so
|
||||
* these tests cannot wrap themselves in one -- a rollback would fight the manager's commits.
|
||||
* They clean up explicitly instead.
|
||||
*/
|
||||
class BookingManagerTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private BookingManager $manager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$this->em = self::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->manager = self::getContainer()->get(BookingManager::class);
|
||||
$this->wipe();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->wipe();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testAFreeResourceIsBooked(): void
|
||||
{
|
||||
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
self::assertNotNull($booking->getId());
|
||||
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
|
||||
}
|
||||
|
||||
public function testAnOverlappingSpanOnTheSameResourceIsRefused(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
$this->expectException(ResourceAlreadyBookedException::class);
|
||||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(660, 900));
|
||||
}
|
||||
|
||||
public function testATouchingSpanOnTheSameResourceIsAllowed(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
$second = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
|
||||
|
||||
self::assertNotNull($second->getId());
|
||||
}
|
||||
|
||||
public function testOnePersonMayNotHoldTwoDesksAtOnce(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
$this->expectException(AlreadyHoldingResourceException::class);
|
||||
$this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(600, 720));
|
||||
}
|
||||
|
||||
public function testOnePersonMayHoldTwoDesksAtDifferentTimes(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$this->manager->book($user, $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
$afternoon = $this->manager->book($user, $this->resource('D2'), $this->date(), TimeRange::fromMinutes(780, 1020));
|
||||
|
||||
self::assertNotNull($afternoon->getId());
|
||||
}
|
||||
|
||||
public function testADeskAndAParkingSpotAtTheSameTimeAreBothAllowed(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$time = TimeRange::fromMinutes(540, 780);
|
||||
$this->manager->book($user, $this->resource('D1'), $this->date(), $time);
|
||||
|
||||
$parking = $this->manager->book($user, $this->resource('P1', ResourceType::PARKING), $this->date(), $time);
|
||||
|
||||
self::assertNotNull($parking->getId());
|
||||
}
|
||||
|
||||
public function testASeriesMaterialisesOneBookingPerOccurrence(): void
|
||||
{
|
||||
// 2026-10-05 is a Monday.
|
||||
$series = new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$this->resource('D1'),
|
||||
RecurrenceRule::WEEKLY,
|
||||
new \DateTimeImmutable('2026-10-05'),
|
||||
new \DateTimeImmutable('2026-10-30'),
|
||||
TimeRange::fromMinutes(540, 720),
|
||||
[1, 4],
|
||||
);
|
||||
|
||||
$result = $this->manager->bookSeries($series);
|
||||
|
||||
self::assertSame(8, $result->createdCount());
|
||||
self::assertSame(0, $result->skippedCount());
|
||||
foreach ($result->created as $booking) {
|
||||
self::assertContains((int) $booking->getDate()->format('N'), [1, 4]);
|
||||
self::assertSame($series, $booking->getSeries());
|
||||
}
|
||||
}
|
||||
|
||||
public function testADailySeriesCoversEveryDay(): void
|
||||
{
|
||||
$result = $this->manager->bookSeries(new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$this->resource('D1'),
|
||||
RecurrenceRule::DAILY,
|
||||
new \DateTimeImmutable('2026-10-05'),
|
||||
new \DateTimeImmutable('2026-10-11'),
|
||||
TimeRange::fromMinutes(540, 720),
|
||||
));
|
||||
|
||||
self::assertSame(7, $result->createdCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of materialising: a taken day is reported rather than failing the whole series.
|
||||
*/
|
||||
public function testASeriesSkipsDaysThatAreAlreadyTaken(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-08'), TimeRange::fromMinutes(600, 660));
|
||||
|
||||
$result = $this->manager->bookSeries(new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$desk,
|
||||
RecurrenceRule::WEEKLY,
|
||||
new \DateTimeImmutable('2026-10-05'),
|
||||
new \DateTimeImmutable('2026-10-30'),
|
||||
TimeRange::fromMinutes(540, 720),
|
||||
[1, 4],
|
||||
));
|
||||
|
||||
self::assertSame(7, $result->createdCount());
|
||||
self::assertSame(['8.10.'], $result->skippedDates());
|
||||
self::assertFalse($result->isCompletelyBlocked());
|
||||
}
|
||||
|
||||
public function testASeriesThatCollidesEverywhereReportsItself(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->manager->book($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-05'));
|
||||
|
||||
$result = $this->manager->bookSeries(new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$desk,
|
||||
RecurrenceRule::WEEKLY,
|
||||
new \DateTimeImmutable('2026-10-05'),
|
||||
new \DateTimeImmutable('2026-10-09'),
|
||||
TimeRange::fromMinutes(540, 720),
|
||||
[1],
|
||||
));
|
||||
|
||||
self::assertTrue($result->isCompletelyBlocked());
|
||||
self::assertSame(0, $result->createdCount());
|
||||
}
|
||||
|
||||
public function testReschedulingAnOccurrenceMarksItAsAnOverride(): void
|
||||
{
|
||||
$series = $this->weeklySeries();
|
||||
$occurrence = $this->manager->bookSeries($series)->created[0];
|
||||
|
||||
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
|
||||
|
||||
self::assertTrue($occurrence->isOverridden());
|
||||
self::assertSame('14:00–16:00', (string) $occurrence->getTimeRange());
|
||||
}
|
||||
|
||||
public function testReschedulingBackOntoTheSeriesTimesClearsTheOverride(): void
|
||||
{
|
||||
$series = $this->weeklySeries();
|
||||
$occurrence = $this->manager->bookSeries($series)->created[0];
|
||||
|
||||
$this->manager->reschedule($occurrence, TimeRange::fromMinutes(840, 960));
|
||||
$this->manager->reschedule($occurrence, $series->getTimeRange());
|
||||
|
||||
self::assertFalse($occurrence->isOverridden());
|
||||
}
|
||||
|
||||
public function testReschedulingIntoSomebodyElsesSpanIsRefused(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$mine = $this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
$this->manager->book($this->user('[email protected]'), $desk, $this->date(), TimeRange::fromMinutes(780, 1020));
|
||||
|
||||
$this->expectException(ResourceAlreadyBookedException::class);
|
||||
$this->manager->reschedule($mine, TimeRange::fromMinutes(540, 900));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rescheduling must not trip over the booking it is moving -- that would make every edit
|
||||
* that keeps any part of its own span impossible.
|
||||
*/
|
||||
public function testAnOccurrenceCanBeShrunkWithoutCollidingWithItself(): void
|
||||
{
|
||||
$booking = $this->manager->book($this->user('[email protected]'), $this->resource('D1'), $this->date(), TimeRange::fromMinutes(540, 780));
|
||||
|
||||
$this->manager->reschedule($booking, TimeRange::fromMinutes(600, 720));
|
||||
|
||||
self::assertSame('10:00–12:00', (string) $booking->getTimeRange());
|
||||
}
|
||||
|
||||
public function testEndingASeriesRemovesItsRemainingOccurrences(): void
|
||||
{
|
||||
$series = $this->weeklySeries();
|
||||
$this->manager->bookSeries($series);
|
||||
|
||||
$id = $series->getId();
|
||||
|
||||
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-05'));
|
||||
|
||||
self::assertSame(8, $removed);
|
||||
self::assertSame([], $this->em->getRepository(Booking::class)->findBy(['series' => $id]));
|
||||
// Nothing of the series is left, so the series row goes too -- note $series has no id
|
||||
// any more at this point, which is why the lookup goes through the captured one.
|
||||
self::assertNull($this->em->getRepository(BookingSeries::class)->find($id));
|
||||
}
|
||||
|
||||
public function testEndingASeriesPartWayThroughKeepsThePastAndShortensIt(): void
|
||||
{
|
||||
$series = $this->weeklySeries();
|
||||
$this->manager->bookSeries($series);
|
||||
|
||||
$removed = $this->manager->endSeries($series, new \DateTimeImmutable('2026-10-15'));
|
||||
|
||||
self::assertSame(5, $removed);
|
||||
self::assertCount(3, $this->em->getRepository(Booking::class)->findBy(['series' => $series]));
|
||||
self::assertSame('2026-10-14', $series->getEndDate()->format('Y-m-d'));
|
||||
}
|
||||
|
||||
private function weeklySeries(): BookingSeries
|
||||
{
|
||||
return new BookingSeries(
|
||||
$this->user('[email protected]'),
|
||||
$this->resource('D1'),
|
||||
RecurrenceRule::WEEKLY,
|
||||
new \DateTimeImmutable('2026-10-05'),
|
||||
new \DateTimeImmutable('2026-10-30'),
|
||||
TimeRange::fromMinutes(540, 720),
|
||||
[1, 4],
|
||||
);
|
||||
}
|
||||
|
||||
private function date(): \DateTimeImmutable
|
||||
{
|
||||
return new \DateTimeImmutable('2026-10-01');
|
||||
}
|
||||
|
||||
private function user(string $email): User
|
||||
{
|
||||
$user = new User($email, 'Test');
|
||||
$user->setRoles(['ROLE_EMPLOYEE']);
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function resource(string $label, ResourceType $type = ResourceType::DESK): Resource
|
||||
{
|
||||
$resource = new Resource($type, $label, strtolower($label));
|
||||
$this->em->persist($resource);
|
||||
$this->em->flush();
|
||||
|
||||
return $resource;
|
||||
}
|
||||
|
||||
private function wipe(): void
|
||||
{
|
||||
$connection = $this->em->getConnection();
|
||||
$connection->executeStatement('DELETE FROM booking');
|
||||
$connection->executeStatement('DELETE FROM booking_series');
|
||||
$connection->executeStatement('DELETE FROM resource');
|
||||
$connection->executeStatement('DELETE FROM `user`');
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Booking;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\ArrayLoader;
|
||||
|
||||
class TimeRangeTest extends TestCase
|
||||
{
|
||||
public function testAWholeDayRunsFromMidnightToMidnight(): void
|
||||
{
|
||||
$range = TimeRange::wholeDay();
|
||||
|
||||
self::assertTrue($range->isAllDay());
|
||||
self::assertSame(0, $range->startMinute);
|
||||
self::assertSame(1440, $range->endMinute);
|
||||
}
|
||||
|
||||
public function testTheEndMustComeAfterTheStart(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
TimeRange::fromMinutes(13 * 60, 9 * 60);
|
||||
}
|
||||
|
||||
public function testARangeCannotLeaveTheDay(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
TimeRange::fromMinutes(0, 1441);
|
||||
}
|
||||
|
||||
public function testTouchingRangesDoNotOverlap(): void
|
||||
{
|
||||
$morning = TimeRange::fromMinutes(9 * 60, 13 * 60);
|
||||
$afternoon = TimeRange::fromMinutes(13 * 60, 17 * 60);
|
||||
|
||||
// Half-open, so handing a desk over at 13:00 is not a collision.
|
||||
self::assertFalse($morning->overlaps($afternoon));
|
||||
self::assertFalse($afternoon->overlaps($morning));
|
||||
}
|
||||
|
||||
public function testPartlyCoveringRangesOverlapBothWays(): void
|
||||
{
|
||||
$a = TimeRange::fromMinutes(9 * 60, 13 * 60);
|
||||
$b = TimeRange::fromMinutes(12 * 60, 17 * 60);
|
||||
|
||||
self::assertTrue($a->overlaps($b));
|
||||
self::assertTrue($b->overlaps($a));
|
||||
}
|
||||
|
||||
public function testAWholeDayOverlapsEverything(): void
|
||||
{
|
||||
self::assertTrue(TimeRange::wholeDay()->overlaps(TimeRange::fromMinutes(0, 15)));
|
||||
}
|
||||
|
||||
#[DataProvider('urlPairs')]
|
||||
public function testParsingTheBoardsQueryParameters(?string $from, ?string $to, ?string $expected): void
|
||||
{
|
||||
$range = TimeRange::parse($from, $to);
|
||||
|
||||
self::assertSame($expected, null === $range ? null : $range->start().'-'.$range->end());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{?string, ?string, ?string}>
|
||||
*/
|
||||
public static function urlPairs(): iterable
|
||||
{
|
||||
yield 'both given' => ['09:00', '13:00', '09:00-13:00'];
|
||||
yield 'only a start is open to the end of the day' => ['09:00', null, '09:00-24:00'];
|
||||
yield 'only an end is open from midnight' => [null, '13:00', '00:00-13:00'];
|
||||
yield 'neither means the whole day, i.e. no filter' => [null, null, null];
|
||||
yield 'empty strings are the same as absent' => ['', '', null];
|
||||
yield 'reversed is rejected' => ['13:00', '09:00', null];
|
||||
yield 'equal is rejected' => ['09:00', '09:00', null];
|
||||
yield 'nonsense is rejected' => ['halb zehn', '13:00', null];
|
||||
yield 'out of range is rejected' => ['25:00', '26:00', null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression guard for a trap that cost real debugging time: Twig resolves `range.allDay` by
|
||||
* looking for a method literally named `allDay` BEFORE it tries `isAllDay()`, and it happily
|
||||
* calls a static one. While the factory was called allDay(), every template read back a
|
||||
* freshly built all-day range -- always truthy -- so the whole board silently ignored the
|
||||
* selected span. Renaming the factory to wholeDay() is what fixes it; this keeps it fixed.
|
||||
*/
|
||||
public function testTheAllDayAccessorIsNotShadowedInTwig(): void
|
||||
{
|
||||
$twig = new Environment(new ArrayLoader(['t' => '{{ range.allDay ? "yes" : "no" }}']));
|
||||
|
||||
self::assertSame('no', $twig->render('t', ['range' => TimeRange::fromMinutes(9 * 60, 13 * 60)]));
|
||||
self::assertSame('yes', $twig->render('t', ['range' => TimeRange::wholeDay()]));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
@@ -71,7 +72,7 @@ class BookingControllerTest extends WebTestCase
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
// The modal is gone from the response, which is what closes it: an out-of-band-only
|
||||
@@ -89,7 +90,7 @@ class BookingControllerTest extends WebTestCase
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', '2020-01-01');
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', ['allDay' => '1', 'date' => '2020-01-01']);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('id="htmx-modal"', (string) $response->getContent());
|
||||
@@ -107,7 +108,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('ist am 01.10.2026 bereits belegt', (string) $response->getContent());
|
||||
@@ -121,7 +122,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('D2'), '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('bereits D1 gebucht', (string) $response->getContent());
|
||||
@@ -135,7 +136,7 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01', '2026-10-01');
|
||||
$response = $this->submitBookingModal($this->resource('P1', ResourceType::PARKING), '2026-10-01');
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
@@ -183,9 +184,9 @@ class BookingControllerTest extends WebTestCase
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
|
||||
// Read the token off the rendered plan rather than minting one: outside a request there
|
||||
// is no session for the token storage to live in, and this is the real round trip anyway.
|
||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
||||
// Releasing lives in the edit modal now, not on the floor plan -- clicking your own desk
|
||||
// opens that modal, and the token is rendered there.
|
||||
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $id), server: ['HTTP_HX-Request' => 'true']);
|
||||
$vals = $crawler->filter(\sprintf('[hx-post="/buchungen/%d/freigeben"]', $id))->attr('hx-vals');
|
||||
$token = json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token'];
|
||||
|
||||
@@ -196,17 +197,229 @@ class BookingControllerTest extends WebTestCase
|
||||
self::assertNull($this->em->getRepository(Booking::class)->find($id));
|
||||
}
|
||||
|
||||
private function submitBookingModal(Resource $resource, string $routeDate, string $submittedDate): Response
|
||||
public function testTheFloorPlanOpensTheEditModalForYourOwnBooking(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$booking = new Booking($anna, $this->resource('D1', svgElementId: 'desk-01'), new \DateTimeImmutable('2026-10-01'));
|
||||
$this->em->persist($booking);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($anna);
|
||||
$crawler = $this->client->request('GET', '/?tag=2026-10-01');
|
||||
|
||||
self::assertCount(1, $crawler->filter(\sprintf('[hx-get="/buchungen/%d/bearbeiten"]', $booking->getId())));
|
||||
}
|
||||
|
||||
public function testBookingPartOfADay(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '13:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||
self::assertNotNull($booking);
|
||||
self::assertSame('09:00–13:00', (string) $booking->getTimeRange());
|
||||
}
|
||||
|
||||
public function testABookingDefaultsToTheWholeDay(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-01');
|
||||
|
||||
$booking = $this->em->getRepository(Booking::class)->findOneBy(['resource' => $desk]);
|
||||
self::assertNotNull($booking);
|
||||
self::assertTrue($booking->getTimeRange()->isAllDay());
|
||||
}
|
||||
|
||||
public function testAnEndBeforeTheStartIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-01', [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '09:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Das Ende muss nach dem Beginn liegen.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAPartDayBookingLeavesTheRestOfTheDayBookable(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$this->em->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(540, 780)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($desk, '2026-10-01', [
|
||||
'startTime' => '13:00',
|
||||
'endTime' => '17:00',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesIsCreatedAndReported(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
// 2026-10-05 is a Monday.
|
||||
$response = $this->submitBookingModal($desk, '2026-10-05', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'weekdays' => ['1', '4'],
|
||||
'until' => '2026-10-30',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
self::assertCount(8, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
|
||||
self::assertStringContainsString('8 Termine gebucht', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testASeriesWithoutAnEndDateIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Bitte ein Enddatum für die Serie angeben.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testAWeeklySeriesWithoutWeekdaysIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'weekly',
|
||||
'until' => '2026-10-30',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('Bitte mindestens einen Wochentag wählen.', (string) $response->getContent());
|
||||
}
|
||||
|
||||
public function testASeriesRunningTooFarOutIsRejected(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$response = $this->submitBookingModal($this->resource('D1'), '2026-10-05', [
|
||||
'allDay' => '1',
|
||||
'repeat' => 'daily',
|
||||
'until' => '2030-01-01',
|
||||
]);
|
||||
|
||||
self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
|
||||
self::assertStringContainsString('höchstens 12 Monate', (string) $response->getContent());
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's span is carried into the modal, so narrowing the board and clicking a desk
|
||||
* books exactly what was on screen.
|
||||
*/
|
||||
public function testTheModalIsPrefilledFromTheBoardsSpan(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$crawler = $this->client->request(
|
||||
'GET',
|
||||
\sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate),
|
||||
\sprintf('/buchungen/neu/%d/2026-10-01?von=09:00&bis=13:00', $desk->getId()),
|
||||
server: ['HTTP_HX-Request' => 'true'],
|
||||
);
|
||||
|
||||
self::assertSame('09:00', $crawler->filter('#booking_startTime')->attr('value'));
|
||||
self::assertSame('13:00', $crawler->filter('#booking_endTime')->attr('value'));
|
||||
self::assertNull($crawler->filter('#booking_allDay')->attr('checked'));
|
||||
}
|
||||
|
||||
public function testTheBoardNarrowsToASpan(): void
|
||||
{
|
||||
$this->client->loginUser($this->user('[email protected]'));
|
||||
|
||||
$this->client->request('GET', '/tag/2026-10-01?von=09:00&bis=13:00', server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSame('/?tag=2026-10-01&von=09:00&bis=13:00', $this->client->getResponse()->headers->get('HX-Push-Url'));
|
||||
}
|
||||
|
||||
public function testOverridingOneOccurrenceLeavesTheSeriesAlone(): void
|
||||
{
|
||||
$anna = $this->user('[email protected]');
|
||||
$this->client->loginUser($anna);
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->submitBookingModal($desk, '2026-10-05', [
|
||||
'startTime' => '09:00',
|
||||
'endTime' => '12:00',
|
||||
'repeat' => 'weekly',
|
||||
'weekdays' => ['1'],
|
||||
'until' => '2026-10-26',
|
||||
]);
|
||||
|
||||
$occurrences = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||
self::assertCount(4, $occurrences);
|
||||
$first = $occurrences[0];
|
||||
|
||||
$crawler = $this->client->request('GET', \sprintf('/buchungen/%d/bearbeiten', $first->getId()), server: ['HTTP_HX-Request' => 'true']);
|
||||
// Only the span is editable here: the day and the recurrence belong to the series.
|
||||
self::assertCount(0, $crawler->filter('#booking_date'));
|
||||
self::assertCount(0, $crawler->filter('#booking_repeat'));
|
||||
|
||||
$form = $crawler->selectButton('Speichern')->form([
|
||||
'booking[startTime]' => '14:00',
|
||||
'booking[endTime]' => '16:00',
|
||||
]);
|
||||
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Booking::class)->findBy(['resource' => $desk], ['date' => 'ASC']);
|
||||
self::assertSame('14:00–16:00', (string) $reloaded[0]->getTimeRange());
|
||||
self::assertTrue($reloaded[0]->isOverridden());
|
||||
// The other three are untouched.
|
||||
foreach (\array_slice($reloaded, 1) as $untouched) {
|
||||
self::assertSame('09:00–12:00', (string) $untouched->getTimeRange());
|
||||
self::assertFalse($untouched->isOverridden());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the modal, reads its CSRF token, and posts the given booking fields back to it.
|
||||
*
|
||||
* Posted explicitly rather than through DomCrawler's form(): the weekday checkboxes are an
|
||||
* expanded multiple choice rendered as booking[weekdays][], which the crawler's field
|
||||
* registry indexes positionally, so assigning ['1', '4'] sets the second checkbox instead of
|
||||
* the fourth. The token still comes off the rendered form, so the round trip is real.
|
||||
*
|
||||
* @param array<string, mixed> $fields
|
||||
*/
|
||||
private function submitBookingModal(Resource $resource, string $routeDate, array $fields = ['allDay' => '1']): Response
|
||||
{
|
||||
$url = \sprintf('/buchungen/neu/%d/%s', $resource->getId(), $routeDate);
|
||||
$crawler = $this->client->request('GET', $url, server: ['HTTP_HX-Request' => 'true']);
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$form = $crawler->selectButton('Buchen')->form(['booking[date]' => $submittedDate]);
|
||||
$this->client->submit($form, serverParameters: ['HTTP_HX-Request' => 'true']);
|
||||
$fields += [
|
||||
'date' => $routeDate,
|
||||
'_token' => $crawler->filter('#booking__token')->attr('value'),
|
||||
];
|
||||
|
||||
$this->client->request('POST', $url, ['booking' => $fields], server: ['HTTP_HX-Request' => 'true']);
|
||||
|
||||
return $this->client->getResponse();
|
||||
}
|
||||
|
||||
@@ -4,18 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository;
|
||||
|
||||
use App\Booking\TimeRange;
|
||||
use App\Entity\Booking;
|
||||
use App\Entity\Resource;
|
||||
use App\Entity\ResourceType;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingRepository;
|
||||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Hits the real database, because the behaviour under test is the database's: the unique
|
||||
* constraint is what actually prevents double-booking.
|
||||
* Hits the real database, because these are queries.
|
||||
*
|
||||
* Note what is NOT here any more: the double-booking guard. It used to be a unique index on
|
||||
* (resource, date) and was tested by asserting the insert blew up. A booking is now a time range,
|
||||
* which no unique index can guard, so the guard lives in BookingManager and is tested there --
|
||||
* what this class covers is the overlap query that guard relies on.
|
||||
*
|
||||
* Each test runs inside a transaction that is rolled back afterwards.
|
||||
*/
|
||||
@@ -38,7 +42,30 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheSameResourceCannotBeBookedTwiceOnOneDay(): void
|
||||
public function testFindOverlappingSeesASpanThatCollides(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(11 * 60, 15 * 60)));
|
||||
}
|
||||
|
||||
public function testFindOverlappingTreatsTouchingSpansAsFree(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date, TimeRange::fromMinutes(9 * 60, 13 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
// Half-open: 09:00-13:00 and 13:00-17:00 do not collide.
|
||||
self::assertSame([], $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(13 * 60, 17 * 60)));
|
||||
}
|
||||
|
||||
public function testAnAllDayBookingBlocksEverySpan(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
@@ -46,12 +73,7 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, $date));
|
||||
|
||||
// The controller catches exactly this and re-renders the fragment with an inline error,
|
||||
// rather than pre-checking, which would be racy.
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
self::assertCount(1, $this->bookings->findOverlapping($desk, $date, TimeRange::fromMinutes(8 * 60, 8 * 60 + 15)));
|
||||
}
|
||||
|
||||
public function testTheSameResourceOnAnotherDayIsFine(): void
|
||||
@@ -66,17 +88,31 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
|
||||
}
|
||||
|
||||
public function testATimeComponentDoesNotCreateASecondSlotForTheSameDay(): void
|
||||
public function testATimeComponentOnTheDateDoesNotShiftWhichDayABookingIsOn(): void
|
||||
{
|
||||
$desk = $this->resource('D1');
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 08:00')));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
self::assertCount(1, $this->bookings->findForDate(new \DateTimeImmutable('2026-10-01')));
|
||||
}
|
||||
|
||||
public function testFindForDateOnlyReturnsWhatOverlapsTheSpan(): void
|
||||
{
|
||||
$date = new \DateTimeImmutable('2026-10-01');
|
||||
$user = $this->user('[email protected]');
|
||||
|
||||
$this->persist(new Booking($user, $this->resource('D1'), $date, TimeRange::fromMinutes(9 * 60, 12 * 60)));
|
||||
$this->persist(new Booking($user, $this->resource('D2'), $date, TimeRange::fromMinutes(14 * 60, 17 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
$this->persist(new Booking($this->user('[email protected]'), $desk, new \DateTimeImmutable('2026-10-01 17:30')));
|
||||
$morning = $this->bookings->findForDate($date, TimeRange::fromMinutes(8 * 60, 13 * 60));
|
||||
|
||||
$this->expectException(UniqueConstraintViolationException::class);
|
||||
$this->em->flush();
|
||||
self::assertCount(1, $morning);
|
||||
self::assertSame('D1', $morning[0]->getResource()->getLabel());
|
||||
self::assertCount(2, $this->bookings->findForDate($date));
|
||||
}
|
||||
|
||||
public function testFindForDateCarriesTheBookingHolderForTheTeamView(): void
|
||||
@@ -92,6 +128,24 @@ class BookingRepositoryTest extends KernelTestCase
|
||||
self::assertSame('D1', $bookings[0]->getResource()->getLabel());
|
||||
}
|
||||
|
||||
public function testCountPerDateOnlyCountsWhatOverlapsTheSpan(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
$this->persist(new Booking($user, $this->resource('D1'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(9 * 60, 12 * 60)));
|
||||
$this->persist(new Booking($this->user('[email protected]'), $this->resource('D2'), new \DateTimeImmutable('2026-10-01'), TimeRange::fromMinutes(14 * 60, 17 * 60)));
|
||||
$this->em->flush();
|
||||
|
||||
$counts = $this->bookings->countPerDateInRange(
|
||||
new \DateTimeImmutable('2026-10-01'),
|
||||
new \DateTimeImmutable('2026-10-31'),
|
||||
TimeRange::fromMinutes(8 * 60, 13 * 60),
|
||||
);
|
||||
|
||||
// The calendar number has to mean the same thing as the colours on the plan, so it is
|
||||
// filtered by the same span.
|
||||
self::assertSame(['2026-10-01' => 1], $counts);
|
||||
}
|
||||
|
||||
public function testCountPerDateInRangeIsKeyedByDay(): void
|
||||
{
|
||||
$user = $this->user('[email protected]');
|
||||
|
||||
Reference in new issue
Block a user