diff --git a/assets/icons/lucide/history.svg b/assets/icons/lucide/history.svg
new file mode 100644
index 0000000..814fe5a
--- /dev/null
+++ b/assets/icons/lucide/history.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/migrations/Version20261016135717.php b/migrations/Version20261016135717.php
new file mode 100644
index 0000000..2f06d91
--- /dev/null
+++ b/migrations/Version20261016135717.php
@@ -0,0 +1,34 @@
+addSql('CREATE TABLE booking_event (id INT AUTO_INCREMENT NOT NULL, occurred_at DATETIME NOT NULL, type VARCHAR(32) NOT NULL, actor_name VARCHAR(100) NOT NULL, holder_name VARCHAR(100) DEFAULT NULL, resource_id INT DEFAULT NULL, resource_label VARCHAR(50) NOT NULL, resource_type VARCHAR(32) NOT NULL, date DATE DEFAULT NULL, end_date DATE DEFAULT NULL, start_minute INT DEFAULT NULL, end_minute INT DEFAULT NULL, previous_start_minute INT DEFAULT NULL, previous_end_minute INT DEFAULT NULL, booking_id INT DEFAULT NULL, series_id INT DEFAULT NULL, details JSON NOT NULL, actor_id INT DEFAULT NULL, holder_id INT DEFAULT NULL, INDEX idx_booking_event_occurred_at (occurred_at), INDEX idx_booking_event_actor_occurred_at (actor_id, occurred_at), INDEX IDX_AAD2F71210DAF24A (actor_id), INDEX IDX_AAD2F712DEEE62D0 (holder_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
+ $this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F71210DAF24A FOREIGN KEY (actor_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ $this->addSql('ALTER TABLE booking_event ADD CONSTRAINT FK_AAD2F712DEEE62D0 FOREIGN KEY (holder_id) REFERENCES `user` (id) ON DELETE SET NULL');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F71210DAF24A');
+ $this->addSql('ALTER TABLE booking_event DROP FOREIGN KEY FK_AAD2F712DEEE62D0');
+ $this->addSql('DROP TABLE booking_event');
+ }
+}
diff --git a/src/Command/PruneBookingEventsCommand.php b/src/Command/PruneBookingEventsCommand.php
new file mode 100644
index 0000000..9a8c43d
--- /dev/null
+++ b/src/Command/PruneBookingEventsCommand.php
@@ -0,0 +1,61 @@
+addOption(
+ 'months',
+ null,
+ InputOption::VALUE_REQUIRED,
+ 'How many months of history to keep',
+ (string) BookingEvent::RETENTION_MONTHS,
+ );
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+
+ $months = filter_var($input->getOption('months'), \FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
+
+ if (false === $months) {
+ $io->error('--months must be a positive whole number.');
+
+ return Command::INVALID;
+ }
+
+ $cutoff = new \DateTimeImmutable(\sprintf('today -%d months', $months));
+ $deleted = $this->events->deleteOlderThan($cutoff);
+
+ $io->success(\sprintf('Deleted %d booking events from before %s.', $deleted, $cutoff->format('Y-m-d')));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/Admin/BookingEvent/IndexController.php b/src/Controller/Admin/BookingEvent/IndexController.php
new file mode 100644
index 0000000..3fd0a58
--- /dev/null
+++ b/src/Controller/Admin/BookingEvent/IndexController.php
@@ -0,0 +1,76 @@
+createForm(BookingEventFilterType::class, $filter);
+ $form->handleRequest($request);
+
+ $pagination = $this->paginator->paginate(
+ $this->events->createFilteredQueryBuilder($filter),
+ $request->query->getInt('page', 1),
+ self::PER_PAGE,
+ [
+ 'defaultSortFieldName' => 'e.occurredAt',
+ 'defaultSortDirection' => 'desc',
+ 'sortFieldAllowList' => self::SORTABLE,
+ ],
+ );
+
+ return $this->render('admin/booking_events.html.twig', [
+ 'form' => $form,
+ 'filter' => $filter,
+ 'pagination' => $pagination,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/Resource/DeleteController.php b/src/Controller/Admin/Resource/DeleteController.php
index 6e01747..724a47b 100644
--- a/src/Controller/Admin/Resource/DeleteController.php
+++ b/src/Controller/Admin/Resource/DeleteController.php
@@ -13,6 +13,7 @@ use App\Repository\BookingRepository;
use App\Security\Role;
use Doctrine\DBAL\LockMode;
use App\Service\AuditLogger;
+use App\Service\BookingEventRecorder;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -39,6 +40,7 @@ final class DeleteController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly BookingRepository $bookings,
private readonly AuditLogger $audit,
+ private readonly BookingEventRecorder $events,
) {
}
@@ -66,7 +68,7 @@ final class DeleteController extends AbstractController
$label = $resource->getLabel();
$type = $resource->getType();
- $deleted = $this->em->wrapInTransaction(function () use ($resource): int {
+ $deleted = $this->em->wrapInTransaction(function () use ($resource, $id, $label, $type): int {
$this->em->lock($resource, LockMode::PESSIMISTIC_WRITE);
$deleted = $this->em->createQueryBuilder()
@@ -84,6 +86,7 @@ final class DeleteController extends AbstractController
->execute();
$this->em->remove($resource);
+ $this->events->resourceDeleted($id, $label, $type, (int) $deleted);
$this->em->flush();
return (int) $deleted;
diff --git a/src/Entity/BookingEvent.php b/src/Entity/BookingEvent.php
new file mode 100644
index 0000000..8895f18
--- /dev/null
+++ b/src/Entity/BookingEvent.php
@@ -0,0 +1,287 @@
+
+ */
+ #[ORM\Column(type: Types::JSON)]
+ private array $details = [];
+
+ public function __construct(
+ BookingEventType $type,
+ ?User $actor,
+ string $actorName,
+ ?int $resourceId,
+ string $resourceLabel,
+ ResourceType $resourceType,
+ ) {
+ $this->occurredAt = new \DateTimeImmutable();
+ $this->type = $type;
+ $this->actor = $actor;
+ $this->actorName = $actorName;
+ $this->resourceId = $resourceId;
+ $this->resourceLabel = $resourceLabel;
+ $this->resourceType = $resourceType;
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getOccurredAt(): \DateTimeImmutable
+ {
+ return $this->occurredAt;
+ }
+
+ public function getType(): BookingEventType
+ {
+ return $this->type;
+ }
+
+ public function getActor(): ?User
+ {
+ return $this->actor;
+ }
+
+ public function getActorName(): string
+ {
+ return $this->actorName;
+ }
+
+ public function getHolder(): ?User
+ {
+ return $this->holder;
+ }
+
+ public function getHolderName(): ?string
+ {
+ return $this->holderName;
+ }
+
+ public function setHolder(User $holder): self
+ {
+ $this->holder = $holder;
+ $this->holderName = $holder->getName();
+
+ return $this;
+ }
+
+ /**
+ * Somebody acting on another person's booking -- a supervisor freeing a desk, say. Compared by
+ * id while both accounts exist and by name once one has gone, which is the best left to go on.
+ */
+ public function isOnBehalf(): bool
+ {
+ if (null === $this->holderName) {
+ return false;
+ }
+
+ if (null !== $this->actor && null !== $this->holder) {
+ return $this->actor->getId() !== $this->holder->getId();
+ }
+
+ return $this->actorName !== $this->holderName;
+ }
+
+ public function getResourceId(): ?int
+ {
+ return $this->resourceId;
+ }
+
+ public function getResourceLabel(): string
+ {
+ return $this->resourceLabel;
+ }
+
+ public function getResourceType(): ResourceType
+ {
+ return $this->resourceType;
+ }
+
+ public function getDate(): ?\DateTimeImmutable
+ {
+ return $this->date;
+ }
+
+ public function getEndDate(): ?\DateTimeImmutable
+ {
+ return $this->endDate;
+ }
+
+ public function setDates(\DateTimeImmutable $date, ?\DateTimeImmutable $endDate = null): self
+ {
+ $this->date = $date;
+ $this->endDate = $endDate;
+
+ return $this;
+ }
+
+ public function getTimeRange(): ?TimeRange
+ {
+ return null === $this->startMinute || null === $this->endMinute
+ ? null
+ : TimeRange::fromMinutes($this->startMinute, $this->endMinute);
+ }
+
+ public function getPreviousTimeRange(): ?TimeRange
+ {
+ return null === $this->previousStartMinute || null === $this->previousEndMinute
+ ? null
+ : TimeRange::fromMinutes($this->previousStartMinute, $this->previousEndMinute);
+ }
+
+ public function setTimeRange(TimeRange $time, ?TimeRange $previous = null): self
+ {
+ $this->startMinute = $time->startMinute;
+ $this->endMinute = $time->endMinute;
+ $this->previousStartMinute = $previous?->startMinute;
+ $this->previousEndMinute = $previous?->endMinute;
+
+ return $this;
+ }
+
+ public function getBookingId(): ?int
+ {
+ return $this->bookingId;
+ }
+
+ public function getSeriesId(): ?int
+ {
+ return $this->seriesId;
+ }
+
+ public function setIds(?int $bookingId, ?int $seriesId): self
+ {
+ $this->bookingId = $bookingId;
+ $this->seriesId = $seriesId;
+
+ return $this;
+ }
+
+ /**
+ * @return array Angelegte, geänderte und freigegebene Buchungen der letzten zwölf Monate.
+ {{ filter.fromDate|format_datetime(pattern: 'd. MMMM y') }} bis
+ {{ filter.toDate|format_datetime(pattern: 'd. MMMM y') }}
+ · {{ pagination.totalItemCount }} {{ pagination.totalItemCount == 1 ? 'Ereignis' : 'Ereignisse' }}
+ Ereignishistorie
+
+
+
+ {{ knp_pagination_render(pagination) }}
+
+
+
+
+ {% for event in pagination %}
+ {% set type = event.type.value %}
+ {{ knp_pagination_sortable(pagination, 'Zeitpunkt', 'e.occurredAt') }}
+ {{ knp_pagination_sortable(pagination, 'Ereignis', 'e.type') }}
+ {{ knp_pagination_sortable(pagination, 'Ressource', 'e.resourceLabel') }}
+ {{ knp_pagination_sortable(pagination, 'Buchung', 'e.date') }}
+ {{ knp_pagination_sortable(pagination, 'Für', 'e.holderName') }}
+ {{ knp_pagination_sortable(pagination, 'Durchgeführt von', 'e.actorName') }}
+
+
+ {% else %}
+ {{ event.occurredAt|format_datetime(pattern: 'dd.MM.y, HH:mm') }}
+
+ {{ event.type.label }}
+
+
+ {{ event.resourceLabel }}
+ {{ event.resourceType.label }}
+
+
+ {% if type in ['series_created', 'series_ended'] %}
+ {{ event.rule ? event.rule.label }}{% if event.timeRange %}, {{ event.timeRange }}{% endif %}
+
+ {% if type == 'series_created' %}
+ {{ event.date|format_datetime(pattern: 'd. MMM y') }} – {{ event.endDate|format_datetime(pattern: 'd. MMM y') }}
+ · {{ event.details.created }} {{ event.details.created == 1 ? 'Termin' : 'Termine' }}
+ {%- if event.details.skipped is not empty %}, {{ event.details.skipped|length }} übersprungen{% endif %}
+ {% else %}
+ ab {{ event.date|format_datetime(pattern: 'd. MMM y') }}
+ · {{ event.details.removed }} {{ event.details.removed == 1 ? 'Termin' : 'Termine' }} entfernt
+ {% endif %}
+
+ {% elseif type == 'resource_deleted' %}
+ {{ event.details.removed }} {{ event.details.removed == 1 ? 'Buchung' : 'Buchungen' }} gelöscht
+ {% else %}
+ {{ event.date|format_datetime(pattern: 'EEE, d. MMM y') }}
+
+ {% if event.previousTimeRange %}
+ {{ event.previousTimeRange }} → {{ event.timeRange }}
+ {% else %}
+ {{ event.timeRange }}
+ {% endif %}
+ {%- if event.seriesId %} · wiederkehrend{% endif %}
+
+ {% endif %}
+
+ {{ event.holderName ?? '–' }}
+
+ {{ event.actorName }}
+ {% if event.onBehalf %}
+ stellvertretend
+ {% endif %}
+
+
+
+ {% endfor %}
+
+ Keine Ereignisse in diesem Zeitraum.
+
+ Nachvollziehen, wer welche Buchung angelegt, geändert oder freigegeben hat. +
+ {% endif %} {% endblock %} diff --git a/tests/Command/PruneBookingEventsCommandTest.php b/tests/Command/PruneBookingEventsCommandTest.php new file mode 100644 index 0000000..4e492ea --- /dev/null +++ b/tests/Command/PruneBookingEventsCommandTest.php @@ -0,0 +1,83 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + $this->command = new CommandTester((new Application(self::$kernel))->find('app:booking-events:prune')); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + public function testEventsOlderThanAYearAreDeleted(): void + { + $old = $this->event('-13 months'); + $recent = $this->event('-11 months'); + + $this->command->execute([]); + + $this->command->assertCommandIsSuccessful(); + self::assertStringContainsString('Deleted 1 booking events', $this->command->getDisplay()); + self::assertNull($this->em->find(BookingEvent::class, $old)); + self::assertNotNull($this->em->find(BookingEvent::class, $recent)); + } + + public function testTheRetentionCanBeShortened(): void + { + $event = $this->event('-2 months'); + + $this->command->execute(['--months' => '1']); + + $this->command->assertCommandIsSuccessful(); + self::assertNull($this->em->find(BookingEvent::class, $event)); + } + + public function testANonsensicalRetentionIsRefused(): void + { + $event = $this->event('-2 months'); + + self::assertSame(Command::INVALID, $this->command->execute(['--months' => '0'])); + self::assertNotNull($this->em->find(BookingEvent::class, $event)); + } + + private function event(string $when): int + { + $event = new BookingEvent(BookingEventType::BOOKING_CREATED, null, 'System', null, 'Tisch 1', ResourceType::DESK); + $this->em->persist($event); + $this->em->flush(); + + $this->em->getConnection()->executeStatement( + 'UPDATE booking_event SET occurred_at = ? WHERE id = ?', + [(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()], + ); + $this->em->clear(); + + return (int) $event->getId(); + } +} diff --git a/tests/Controller/Admin/BookingEventControllerTest.php b/tests/Controller/Admin/BookingEventControllerTest.php new file mode 100644 index 0000000..91d37bf --- /dev/null +++ b/tests/Controller/Admin/BookingEventControllerTest.php @@ -0,0 +1,313 @@ +client = self::createClient(server: ['HTTPS' => true]); + $this->client->disableReboot(); + $this->em = self::getContainer()->get(EntityManagerInterface::class); + $this->em->getConnection()->beginTransaction(); + } + + protected function tearDown(): void + { + $this->em->getConnection()->rollBack(); + parent::tearDown(); + } + + /** + * Supervisors free bookings themselves, but the history across the whole staff is the + * administrators' alone. + */ + public function testOnlyAnAdminCanReachTheHistory(): void + { + $this->client->loginUser($this->user('anna@ep-reisen.de')); + $this->client->request('GET', '/admin/history'); + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR'])); + $this->client->request('GET', '/admin/history'); + self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN); + + $this->client->loginUser($this->admin()); + $this->client->request('GET', '/admin/history'); + self::assertResponseIsSuccessful(); + } + + public function testTheDashboardTileIsForAdminsOnly(): void + { + $this->client->loginUser($this->user('sam@ep-reisen.de', 'Sam Supervisor', ['ROLE_EMPLOYEE', 'ROLE_SUPERVISOR'])); + self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/history"]')); + + $this->client->loginUser($this->admin()); + self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/history"]')); + } + + public function testAReleaseOnSomebodysBehalfShowsBothPeople(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $booking = new Booking($bea, $this->resource('Tisch 12'), new \DateTimeImmutable('today')); + $this->em->persist($booking); + $this->em->flush(); + $this->client->loginUser($this->admin()); + + $uri = \sprintf('/bookings/%d/release', $booking->getId()); + $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']); + $crawler = $this->client->request('GET', '/admin/history'); + + self::assertResponseIsSuccessful(); + $row = $crawler->filter('tbody tr')->first()->text(); + self::assertStringContainsString('Buchung freigegeben', $row); + self::assertStringContainsString('Tisch 12', $row); + self::assertStringContainsString('Bea Berger', $row); + self::assertStringContainsString('Chef Chefin', $row); + self::assertStringContainsString('stellvertretend', $row); + } + + public function testSomebodyActingOnTheirOwnBookingIsNotMarked(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/history'); + + self::assertStringNotContainsString('stellvertretend', $crawler->filter('tbody')->text()); + } + + /** + * The person filter is about who carried the change out: Carl's booking freed by Bea is + * Bea's line, not Carl's. + */ + public function testThePersonFilterMatchesWhoActed(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad'); + $this->event(BookingEventType::BOOKING_RELEASED, $bea, $carl, 'Tisch 1'); + $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2'); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/history?actor='.$bea->getId()); + + self::assertResponseIsSuccessful(); + $text = $crawler->filter('tbody')->text(); + self::assertStringContainsString('Tisch 1', $text); + self::assertStringNotContainsString('Tisch 2', $text); + } + + public function testTheTypeFilterAcceptsSeveralKinds(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); + $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea, 'Tisch 2'); + $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea, 'Tisch 3'); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/history?types[]=booking_created&types[]=booking_released'); + + self::assertResponseIsSuccessful(); + $text = $crawler->filter('tbody')->text(); + self::assertStringContainsString('Tisch 1', $text); + self::assertStringNotContainsString('Tisch 2', $text); + self::assertStringContainsString('Tisch 3', $text); + } + + public function testTheResourceTypeFilter(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'P1', ResourceType::PARKING); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/history?resourceType=parking'); + + $text = $crawler->filter('tbody')->text(); + self::assertStringContainsString('P1', $text); + self::assertStringNotContainsString('Tisch 1', $text); + } + + /** + * The window is about when it happened. The default reaches back thirty days, and the "to" + * day is included in full, not cut off at its midnight. + */ + public function testTheDateWindowIsAboutWhenItHappened(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch alt'), '-40 days'); + $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch gestern'), 'yesterday 23:30'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch heute'); + $this->em->clear(); + $this->client->loginUser($this->admin()); + + $text = $this->client->request('GET', '/admin/history')->filter('tbody')->text(); + self::assertStringNotContainsString('Tisch alt', $text); + self::assertStringContainsString('Tisch gestern', $text); + self::assertStringContainsString('Tisch heute', $text); + + $yesterday = (new \DateTimeImmutable('yesterday'))->format('Y-m-d'); + $text = $this->client->request('GET', '/admin/history?from='.$yesterday.'&to='.$yesterday)->filter('tbody')->text(); + self::assertStringContainsString('Tisch gestern', $text); + self::assertStringNotContainsString('Tisch heute', $text); + } + + public function testTheNewestEventComesFirst(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->occurredAt($this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch früher'), '-2 hours'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch später'); + $this->em->clear(); + $this->client->loginUser($this->admin()); + + $rows = $this->client->request('GET', '/admin/history')->filter('tbody tr'); + + self::assertStringContainsString('Tisch später', $rows->eq(0)->text()); + self::assertStringContainsString('Tisch früher', $rows->eq(1)->text()); + + $rows = $this->client->request('GET', '/admin/history?sort=e.occurredAt&direction=asc')->filter('tbody tr'); + self::assertStringContainsString('Tisch früher', $rows->eq(0)->text()); + } + + public function testSortingByWhoActedKeepsTheFilter(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $carl = $this->user('carl@ep-reisen.de', 'Carl Conrad'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea, 'Tisch 1'); + $this->event(BookingEventType::BOOKING_CREATED, $carl, $carl, 'Tisch 2'); + $this->event(BookingEventType::BOOKING_RELEASED, $carl, $carl, 'Tisch 3'); + $this->client->loginUser($this->admin()); + + $rows = $this->client + ->request('GET', '/admin/history?types[]=booking_created&sort=e.actorName&direction=desc') + ->filter('tbody tr'); + + self::assertCount(2, $rows); + self::assertStringContainsString('Carl Conrad', $rows->eq(0)->text()); + self::assertStringContainsString('Bea Berger', $rows->eq(1)->text()); + } + + /** + * Every kind has its own branch in the "Buchung" column; each is rendered once here so a typo + * in one of them cannot hide until somebody filters for it. + */ + public function testEveryKindOfEventRenders(): void + { + $bea = $this->user('bea@ep-reisen.de', 'Bea Berger'); + $this->event(BookingEventType::BOOKING_CREATED, $bea, $bea); + $this->event(BookingEventType::BOOKING_RESCHEDULED, $bea, $bea) + ->setTimeRange(TimeRange::fromMinutes(600, 720), TimeRange::fromMinutes(540, 780)); + $this->event(BookingEventType::BOOKING_RELEASED, $bea, $bea); + $this->event(BookingEventType::SERIES_CREATED, $bea, $bea) + ->setDates(new \DateTimeImmutable('today'), new \DateTimeImmutable('+4 weeks')) + ->setDetails(['rule' => 'weekly', 'created' => 4, 'skipped' => ['2026-10-20']]); + $this->event(BookingEventType::SERIES_ENDED, $bea, $bea) + ->setDetails(['rule' => 'weekly', 'removed' => 3]); + $deleted = new BookingEvent(BookingEventType::RESOURCE_DELETED, $bea, 'Bea Berger', 7, 'Tisch 9', ResourceType::DESK); + $this->em->persist($deleted->setDetails(['removed' => 5])); + $this->em->flush(); + $this->client->loginUser($this->admin()); + + $crawler = $this->client->request('GET', '/admin/history'); + + self::assertResponseIsSuccessful(); + $text = $crawler->filter('tbody')->text(); + self::assertStringContainsString('09:00–13:00 → 10:00–12:00', $text); + self::assertStringContainsString('Wöchentlich', $text); + self::assertStringContainsString('4 Termine, 1 übersprungen', $text); + self::assertStringContainsString('3 Termine entfernt', $text); + self::assertStringContainsString('5 Buchungen gelöscht', $text); + self::assertStringContainsString('6 Ereignisse', $crawler->filter('body')->text()); + } + + private function event( + BookingEventType $type, + User $actor, + User $holder, + string $label = 'Tisch 1', + ResourceType $resourceType = ResourceType::DESK, + ): BookingEvent { + $event = (new BookingEvent($type, $actor, $actor->getName(), null, $label, $resourceType)) + ->setHolder($holder) + ->setDates(new \DateTimeImmutable('today')) + ->setTimeRange(TimeRange::wholeDay()); + $this->em->persist($event); + $this->em->flush(); + + return $event; + } + + /** + * The constructor stamps the current time and nothing should be able to change it afterwards, + * so a test that needs an older event rewrites the row. Call clear() once the setup is done, + * or the page renders the stale time still held in the identity map. + */ + private function occurredAt(BookingEvent $event, string $when): void + { + $this->em->getConnection()->executeStatement( + 'UPDATE booking_event SET occurred_at = ? WHERE id = ?', + [(new \DateTimeImmutable($when))->format('Y-m-d H:i:s'), $event->getId()], + ); + } + + private function confirmationToken(string $uri): string + { + $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']); + self::assertResponseIsSuccessful(); + + $vals = $crawler->filter('#htmx-confirmation-modal [hx-post]')->attr('hx-vals'); + + return json_decode((string) $vals, true, flags: \JSON_THROW_ON_ERROR)['_token']; + } + + private function admin(): User + { + return $this->user('chef@ep-reisen.de', 'Chef Chefin', ['ROLE_ADMIN']); + } + + /** + * @param list