feat: add booking time ranges and repeating series

This commit is contained in:
fromme committed 2026-09-13 17:34:28 +02:00
1 parent 59feca2f8e
commit d433d271d0
29 files changed
+2135 -180

No files matched your search

+105 -7
View File
@@ -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');
}
}