diff --git a/src/Controller/Admin/AccommodationBooking/DeleteController.php b/src/Controller/Admin/AccommodationBooking/DeleteController.php
new file mode 100644
index 0000000..0450aa8
--- /dev/null
+++ b/src/Controller/Admin/AccommodationBooking/DeleteController.php
@@ -0,0 +1,68 @@
+isDraft()) {
+ $this->addFlash('error', 'Nur ein Entwurf lässt sich löschen. Eine Buchung, die bereits beim Kunden war, wird abgesagt.');
+
+ return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking'));
+ }
+
+ if ($request->isMethod(Request::METHOD_POST)) {
+ if (!$this->isCsrfTokenValid('delete_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
+ throw $this->createAccessDeniedException('Invalid CSRF token.');
+ }
+
+ $this->logger->info('Deleted accommodation booking draft', [
+ 'id' => $booking->getId(),
+ 'groupName' => $booking->getGroupName(),
+ 'accommodation' => $booking->getAccommodation()?->getName(),
+ ]);
+
+ $this->entityManager->remove($booking);
+ $this->entityManager->flush();
+
+ $this->addFlash('success', 'Der Entwurf wurde gelöscht');
+
+ // Back to the list the delete was started from, so the filter and the page the
+ // manager was on survive the round trip.
+ return $this->htmxRedirect($request, $this->getReturnUrl($request, 'app_admin_accommodationbooking'));
+ }
+
+ return $this->render('admin/accommodation_booking/modal_delete.html.twig', [
+ 'booking' => $booking,
+ 'csrf_token_id' => 'delete_accommodation_booking_'.$booking->getId(),
+ ]);
+ }
+}
diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig
index d56791a..9ffbb72 100644
--- a/templates/admin/accommodation_booking/index.html.twig
+++ b/templates/admin/accommodation_booking/index.html.twig
@@ -99,6 +99,13 @@
PDF herunterladen
{% endif %}
+ {# A record that has been in front of the customer gets discarded,
+ not removed — see DeleteController. #}
+ {% if booking.draft %}
+
+ löschen
+
+ {% endif %}
diff --git a/templates/admin/accommodation_booking/modal_delete.html.twig b/templates/admin/accommodation_booking/modal_delete.html.twig
new file mode 100644
index 0000000..4031a61
--- /dev/null
+++ b/templates/admin/accommodation_booking/modal_delete.html.twig
@@ -0,0 +1,12 @@
+{% extends 'htmx_confirmation_modal.html.twig' %}
+
+{% block content %}
+
+ Möchtest du den Entwurf für {{ booking.accommodation.name }}
+ {% if booking.groupName %}({{ booking.groupName }}){% endif %}
+ vom {{ booking.dateFrom | date('d.m.Y') }} – {{ booking.dateTo | date('d.m.Y') }}
+ wirklich dauerhaft löschen?
+
+{% endblock %}
+
+{% block button_confirm %}löschen{% endblock %}
diff --git a/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php b/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php
new file mode 100644
index 0000000..7fd70e6
--- /dev/null
+++ b/tests/Controller/Admin/AccommodationBooking/DeleteControllerTest.php
@@ -0,0 +1,177 @@
+createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::never())->method('remove');
+
+ $controller = $this->buildController($entityManager);
+
+ $response = $controller->index($this->draft(), $this->request());
+
+ self::assertSame(Response::HTTP_OK, $response->getStatusCode());
+ self::assertSame('admin/accommodation_booking/modal_delete.html.twig', $controller->renderedView);
+ }
+
+ public function testPostRemovesTheDraftAndReturnsToTheList(): void
+ {
+ $booking = $this->draft();
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::once())->method('remove')->with($booking);
+ $entityManager->expects(self::once())->method('flush');
+
+ $controller = $this->buildController($entityManager);
+
+ $response = $controller->index($booking, $this->request('POST'));
+
+ self::assertSame(
+ '/admin/accommodation-booking?page=2',
+ $response->headers->get('HX-Redirect'),
+ 'the filtered list the delete was started from has to survive the round trip',
+ );
+ self::assertSame('success', $controller->flashes[0]['type']);
+ }
+
+ /**
+ * @dataProvider undeletableStatuses
+ */
+ public function testOnlyADraftCanBeDeleted(AccommodationBookingStatus $status): void
+ {
+ // Anything past Entwurf has been in contact with the customer and is closed by an
+ // Absage instead, which keeps the record readable.
+ $booking = $this->draft();
+ $booking->setStatus($status);
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::never())->method('remove');
+ $entityManager->expects(self::never())->method('flush');
+
+ $controller = $this->buildController($entityManager);
+
+ $response = $controller->index($booking, $this->request('POST'));
+
+ self::assertTrue($response->headers->has('HX-Redirect'));
+ self::assertNull($controller->renderedView);
+ self::assertSame('error', $controller->flashes[0]['type']);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function undeletableStatuses(): iterable
+ {
+ yield 'requested' => [AccommodationBookingStatus::Requested];
+ yield 'open' => [AccommodationBookingStatus::Open];
+ yield 'received' => [AccommodationBookingStatus::Received];
+ yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
+ yield 'discarded' => [AccommodationBookingStatus::Discarded];
+ }
+
+ public function testPostWithAnInvalidTokenIsDenied(): void
+ {
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::never())->method('remove');
+
+ $controller = $this->buildController($entityManager, tokenValid: false);
+
+ $this->expectException(AccessDeniedException::class);
+
+ $controller->index($this->draft(), $this->request('POST'));
+ }
+
+ private function draft(): AccommodationBooking
+ {
+ $booking = new AccommodationBooking();
+ $booking->setStatus(AccommodationBookingStatus::Draft);
+ $booking->setGroupName('Schulklasse 7b');
+ $booking->setAccommodation((new Accommodation())->setName('Seehaus'));
+
+ return $booking;
+ }
+
+ private function request(string $method = 'GET'): Request
+ {
+ // Every entry point is htmx — the dropdown opens the modal, the modal posts back.
+ return Request::create(
+ '/admin/accommodation-booking/1/delete?r=%2Fadmin%2Faccommodation-booking%3Fpage%3D2',
+ $method,
+ server: ['HTTP_HX-Request' => 'true'],
+ );
+ }
+
+ private function buildController(
+ EntityManagerInterface $entityManager,
+ bool $tokenValid = true,
+ ): TestableBookingDeleteController {
+ return new TestableBookingDeleteController(
+ $entityManager,
+ $this->createMock(LoggerInterface::class),
+ $tokenValid,
+ );
+ }
+}
+
+final class TestableBookingDeleteController extends DeleteController
+{
+ public ?string $renderedView = null;
+
+ /** @var list */
+ public array $flashes = [];
+
+ public function __construct(
+ EntityManagerInterface $entityManager,
+ LoggerInterface $logger,
+ private readonly bool $tokenValid = true,
+ ) {
+ parent::__construct($entityManager, $logger);
+ }
+
+ protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
+ {
+ return $this->tokenValid;
+ }
+
+ /**
+ * @param array $parameters
+ */
+ protected function render(string $view, array $parameters = [], ?Response $response = null): Response
+ {
+ $this->renderedView = $view;
+
+ return new Response();
+ }
+
+ protected function addFlash(string $type, mixed $message): void
+ {
+ $this->flashes[] = ['type' => $type, 'message' => $message];
+ }
+
+ /**
+ * @param array $parameters
+ */
+ protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
+ {
+ return '/'.$route.'?'.http_build_query($parameters);
+ }
+}