From 3d67942c627c87c78e0889171553956829ed31d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Thu, 17 Sep 2026 16:25:06 +0200
Subject: [PATCH] feat: add admin crud for resources and rooms
---
.../Admin/Resource/CreateController.php | 62 ++++
.../Admin/Resource/EditController.php | 82 +++++
.../Admin/Resource/IndexController.php | 65 ++++
.../Admin/ResourceGroup/CreateController.php | 64 ++++
.../Admin/ResourceGroup/DeleteController.php | 68 ++++
.../Admin/ResourceGroup/EditController.php | 55 +++
src/Entity/Resource.php | 3 +
src/Entity/ResourceGroup.php | 6 +-
src/Enum/ResourceType.php | 15 +-
src/Form/Admin/ResourceFormType.php | 67 ++++
src/Form/Admin/ResourceGroupFormType.php | 39 +++
src/Repository/BookingRepository.php | 15 +
src/Repository/ResourceRepository.php | 17 +
templates/admin/_modal_resource.html.twig | 41 +++
.../admin/_modal_resource_group.html.twig | 34 ++
.../_modal_resource_group_delete.html.twig | 11 +
templates/admin/resources.html.twig | 86 +++++
templates/dashboard/index.html.twig | 11 +
.../Admin/ResourceAdminControllerTest.php | 322 ++++++++++++++++++
19 files changed, 1059 insertions(+), 4 deletions(-)
create mode 100644 src/Controller/Admin/Resource/CreateController.php
create mode 100644 src/Controller/Admin/Resource/EditController.php
create mode 100644 src/Controller/Admin/Resource/IndexController.php
create mode 100644 src/Controller/Admin/ResourceGroup/CreateController.php
create mode 100644 src/Controller/Admin/ResourceGroup/DeleteController.php
create mode 100644 src/Controller/Admin/ResourceGroup/EditController.php
create mode 100644 src/Form/Admin/ResourceFormType.php
create mode 100644 src/Form/Admin/ResourceGroupFormType.php
create mode 100644 templates/admin/_modal_resource.html.twig
create mode 100644 templates/admin/_modal_resource_group.html.twig
create mode 100644 templates/admin/_modal_resource_group_delete.html.twig
create mode 100644 templates/admin/resources.html.twig
create mode 100644 tests/Controller/Admin/ResourceAdminControllerTest.php
diff --git a/src/Controller/Admin/Resource/CreateController.php b/src/Controller/Admin/Resource/CreateController.php
new file mode 100644
index 0000000..f183b88
--- /dev/null
+++ b/src/Controller/Admin/Resource/CreateController.php
@@ -0,0 +1,62 @@
+createForm(ResourceFormType::class, $resource, [
+ 'action' => $this->generateUrl('app_admin_resource_new', ['type' => $type->value]),
+ ]);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->em->persist($resource);
+ $this->em->flush();
+
+ $this->logger->info('Admin created a resource.', [
+ 'admin' => $this->getUser()?->getUserIdentifier(),
+ 'resource_id' => $resource->getId(),
+ 'type' => $type->value,
+ 'label' => $resource->getLabel(),
+ ]);
+ $this->addFlash('success', \sprintf('%s angelegt.', $resource->getLabel()));
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+ }
+
+ return $this->render('admin/_modal_resource.html.twig', [
+ 'form' => $form,
+ 'resource' => $resource,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/Resource/EditController.php b/src/Controller/Admin/Resource/EditController.php
new file mode 100644
index 0000000..b81e425
--- /dev/null
+++ b/src/Controller/Admin/Resource/EditController.php
@@ -0,0 +1,82 @@
+isActive();
+
+ $form = $this->createForm(ResourceFormType::class, $resource, [
+ 'action' => $this->generateUrl('app_admin_resource_edit', ['id' => $resource->getId()]),
+ 'with_active' => true,
+ ]);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->em->flush();
+
+ $this->logger->info('Admin edited a resource.', [
+ 'admin' => $this->getUser()?->getUserIdentifier(),
+ 'resource_id' => $resource->getId(),
+ 'label' => $resource->getLabel(),
+ 'group' => $resource->getGroup()?->getLabel(),
+ 'active' => $resource->isActive(),
+ ]);
+
+ $remaining = $wasActive && !$resource->isActive()
+ ? $this->bookings->countFromDate($resource, new \DateTimeImmutable('today'))
+ : 0;
+
+ if ($remaining > 0) {
+ $this->addFlash('warning', \sprintf(
+ '%s ist nicht mehr buchbar. %d bestehende Buchungen ab heute bleiben erhalten.',
+ $resource->getLabel(),
+ $remaining,
+ ));
+ } else {
+ $this->addFlash('success', \sprintf('%s gespeichert.', $resource->getLabel()));
+ }
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+ }
+
+ return $this->render('admin/_modal_resource.html.twig', [
+ 'form' => $form,
+ 'resource' => $resource,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/Resource/IndexController.php b/src/Controller/Admin/Resource/IndexController.php
new file mode 100644
index 0000000..06c5d82
--- /dev/null
+++ b/src/Controller/Admin/Resource/IndexController.php
@@ -0,0 +1,65 @@
+resources->findAllByType($type) as $resource) {
+ if (null === $resource->getGroup()) {
+ $ungrouped[] = $resource;
+ } else {
+ $byGroup[$resource->getGroup()->getId()][] = $resource;
+ }
+ }
+
+ $sections[] = [
+ 'type' => $type,
+ // Every group, empty ones too -- an empty room is the only thing that can be
+ // deleted, so it has to be visible.
+ 'groups' => array_map(
+ static fn ($group): array => ['group' => $group, 'resources' => $byGroup[$group->getId()] ?? []],
+ $this->groups->findByType($type),
+ ),
+ 'ungrouped' => $ungrouped,
+ ];
+ }
+
+ return $this->render('admin/resources.html.twig', [
+ 'sections' => $sections,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/ResourceGroup/CreateController.php b/src/Controller/Admin/ResourceGroup/CreateController.php
new file mode 100644
index 0000000..c15893b
--- /dev/null
+++ b/src/Controller/Admin/ResourceGroup/CreateController.php
@@ -0,0 +1,64 @@
+groups->findByType($type);
+ $position = [] === $existing ? 0 : max(array_map(static fn (ResourceGroup $group): int => $group->getPosition(), $existing)) + 1;
+
+ $group = new ResourceGroup($type, '', $position);
+ $form = $this->createForm(ResourceGroupFormType::class, $group, [
+ 'action' => $this->generateUrl('app_admin_resource_group_new', ['type' => $type->value]),
+ ]);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->em->persist($group);
+ $this->em->flush();
+
+ $this->logger->info('Admin created a resource group.', [
+ 'admin' => $this->getUser()?->getUserIdentifier(),
+ 'group_id' => $group->getId(),
+ 'type' => $type->value,
+ 'label' => $group->getLabel(),
+ ]);
+ $this->addFlash('success', \sprintf('%s angelegt.', $group->getLabel()));
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+ }
+
+ return $this->render('admin/_modal_resource_group.html.twig', [
+ 'form' => $form,
+ 'group' => $group,
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/ResourceGroup/DeleteController.php b/src/Controller/Admin/ResourceGroup/DeleteController.php
new file mode 100644
index 0000000..5d15f0a
--- /dev/null
+++ b/src/Controller/Admin/ResourceGroup/DeleteController.php
@@ -0,0 +1,68 @@
+resources->count(['group' => $group]) > 0) {
+ throw $this->createAccessDeniedException('Only an empty resource group can be deleted.');
+ }
+
+ if (!$request->isMethod(Request::METHOD_POST)) {
+ return $this->render('admin/_modal_resource_group_delete.html.twig', [
+ 'group' => $group,
+ 'csrf_token_id' => 'delete-resource-group-'.$group->getId(),
+ ]);
+ }
+
+ $this->denyUnlessCsrfValid($request, 'delete-resource-group-'.$group->getId());
+
+ $this->logger->info('Admin deleted a resource group.', [
+ 'admin' => $this->getUser()?->getUserIdentifier(),
+ 'group_id' => $group->getId(),
+ 'label' => $group->getLabel(),
+ ]);
+
+ $label = $group->getLabel();
+ $this->em->remove($group);
+ $this->em->flush();
+
+ $this->addFlash('success', \sprintf('%s gelöscht.', $label));
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+ }
+}
diff --git a/src/Controller/Admin/ResourceGroup/EditController.php b/src/Controller/Admin/ResourceGroup/EditController.php
new file mode 100644
index 0000000..4b5d709
--- /dev/null
+++ b/src/Controller/Admin/ResourceGroup/EditController.php
@@ -0,0 +1,55 @@
+createForm(ResourceGroupFormType::class, $group, [
+ 'action' => $this->generateUrl('app_admin_resource_group_edit', ['id' => $group->getId()]),
+ ]);
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $this->em->flush();
+
+ $this->logger->info('Admin edited a resource group.', [
+ 'admin' => $this->getUser()?->getUserIdentifier(),
+ 'group_id' => $group->getId(),
+ 'label' => $group->getLabel(),
+ 'position' => $group->getPosition(),
+ ]);
+ $this->addFlash('success', \sprintf('%s gespeichert.', $group->getLabel()));
+
+ return new HxRedirectResponse($this->generateUrl('app_admin_resource'));
+ }
+
+ return $this->render('admin/_modal_resource_group.html.twig', [
+ 'form' => $form,
+ 'group' => $group,
+ ]);
+ }
+}
diff --git a/src/Entity/Resource.php b/src/Entity/Resource.php
index a67ae5c..275d41a 100644
--- a/src/Entity/Resource.php
+++ b/src/Entity/Resource.php
@@ -7,6 +7,7 @@ namespace App\Entity;
use App\Enum\ResourceType;
use App\Repository\ResourceRepository;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
@@ -14,6 +15,7 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
#[ORM\Entity(repositoryClass: ResourceRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_resource_label', columns: ['type', 'label'])]
+#[UniqueEntity(fields: ['type', 'label'], message: 'Diese Bezeichnung ist bereits vergeben.', errorPath: 'label')]
class Resource
{
#[ORM\Id]
@@ -26,6 +28,7 @@ class Resource
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
+ #[Assert\Length(max: 50)]
private string $label;
/**
diff --git a/src/Entity/ResourceGroup.php b/src/Entity/ResourceGroup.php
index 9dcaf3a..a5483a6 100644
--- a/src/Entity/ResourceGroup.php
+++ b/src/Entity/ResourceGroup.php
@@ -7,14 +7,14 @@ namespace App\Entity;
use App\Enum\ResourceType;
use App\Repository\ResourceGroupRepository;
use Doctrine\ORM\Mapping as ORM;
+use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Where a bookable thing physically is: a room of the office, an area of the car park.
*
* A group belongs to exactly one ResourceType, because "Raum Nord" means nothing for parking.
- * It is a description of the building, so it is configuration seeded alongside the resources
- * rather than something a user creates.
+ * It is a description of the building, maintained by an administrator under /admin/resources.
*
* Note there is deliberately no method named `group`, `label` or `position` other than the plain
* accessors: Twig resolves `x.label` to a method of that literal name before trying getLabel(),
@@ -22,6 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert;
*/
#[ORM\Entity(repositoryClass: ResourceGroupRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_resource_group_label', columns: ['type', 'label'])]
+#[UniqueEntity(fields: ['type', 'label'], message: 'Diese Bezeichnung ist bereits vergeben.', errorPath: 'label')]
class ResourceGroup
{
#[ORM\Id]
@@ -34,6 +35,7 @@ class ResourceGroup
#[ORM\Column(length: 50)]
#[Assert\NotBlank]
+ #[Assert\Length(max: 50)]
private string $label;
/** Draw and list order within the type. */
diff --git a/src/Enum/ResourceType.php b/src/Enum/ResourceType.php
index 1c51d04..8a4fbb0 100644
--- a/src/Enum/ResourceType.php
+++ b/src/Enum/ResourceType.php
@@ -8,8 +8,8 @@ namespace App\Enum;
* The kinds of thing that can be booked.
*
* **This enum is the extension point.** The dashboard builds one tile per case, the board route
- * accepts one backing value per case, and the floor plan renders every kind the same way.
- * Adding a third bookable kind is therefore: a case here and rows in `app:seed`.
+ * accepts one backing value per case, and the floor plan and the admin screen render every kind
+ * the same way. Adding a third bookable kind is therefore: a case here.
* Nothing else needs editing — in particular no template lists the kinds by hand.
*
* The backing value is both what is persisted in `resource.type` and what appears in a URL, so
@@ -45,6 +45,17 @@ enum ResourceType: string
};
}
+ /**
+ * What a ResourceGroup of this kind is called: an office has rooms, a car park has areas.
+ */
+ public function groupLabel(): string
+ {
+ return match ($this) {
+ self::DESK => 'Raum',
+ self::PARKING => 'Bereich',
+ };
+ }
+
public function icon(): string
{
return match ($this) {
diff --git a/src/Form/Admin/ResourceFormType.php b/src/Form/Admin/ResourceFormType.php
new file mode 100644
index 0000000..b2ec15f
--- /dev/null
+++ b/src/Form/Admin/ResourceFormType.php
@@ -0,0 +1,67 @@
+getData();
+ \assert($resource instanceof Resource);
+ $type = $resource->getType();
+
+ $builder
+ ->add('label', TextType::class, [
+ 'label' => 'Bezeichnung',
+ ])
+ ->add('group', EntityType::class, [
+ 'label' => $type->groupLabel(),
+ 'class' => ResourceGroup::class,
+ 'required' => false,
+ 'placeholder' => 'Ohne '.$type->groupLabel(),
+ 'choice_label' => 'label',
+ // A group of another type would be tampering, and the choice list rejects it.
+ 'query_builder' => static fn (EntityRepository $repository) => $repository
+ ->createQueryBuilder('g')
+ ->andWhere('g.type = :type')
+ ->setParameter('type', $type)
+ ->orderBy('g.position', 'ASC')
+ ->addOrderBy('g.label', 'ASC'),
+ ]);
+
+ // Only on edit: a resource is created bookable, and retiring it is a later decision.
+ if ($options['with_active']) {
+ $builder->add('active', CheckboxType::class, [
+ 'label' => 'Buchbar',
+ 'help' => 'Nicht buchbare Plätze erscheinen nicht mehr im Plan. Bestehende Buchungen bleiben erhalten.',
+ 'required' => false,
+ ]);
+ }
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => Resource::class,
+ 'with_active' => false,
+ ]);
+ $resolver->setAllowedTypes('with_active', 'bool');
+ }
+}
diff --git a/src/Form/Admin/ResourceGroupFormType.php b/src/Form/Admin/ResourceGroupFormType.php
new file mode 100644
index 0000000..c75beee
--- /dev/null
+++ b/src/Form/Admin/ResourceGroupFormType.php
@@ -0,0 +1,39 @@
+add('label', TextType::class, [
+ 'label' => 'Bezeichnung',
+ ])
+ ->add('position', IntegerType::class, [
+ 'label' => 'Reihenfolge',
+ 'help' => 'Kleinere Zahlen stehen im Plan weiter oben.',
+ // The setter takes an int, so a cleared field must not reach it as null.
+ 'empty_data' => '0',
+ ]);
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => ResourceGroup::class,
+ ]);
+ }
+}
diff --git a/src/Repository/BookingRepository.php b/src/Repository/BookingRepository.php
index 4661e71..a25fdc9 100644
--- a/src/Repository/BookingRepository.php
+++ b/src/Repository/BookingRepository.php
@@ -205,6 +205,21 @@ class BookingRepository extends ServiceEntityRepository
return $qb->getQuery()->getResult();
}
+ /**
+ * Bookings from a day onwards, so retiring a resource can say what it leaves behind.
+ */
+ public function countFromDate(Resource $resource, \DateTimeImmutable $from): int
+ {
+ return (int) $this->createQueryBuilder('b')
+ ->select('COUNT(b.id)')
+ ->andWhere('b.resource = :resource')
+ ->andWhere('b.date >= :from')
+ ->setParameter('resource', $resource)
+ ->setParameter('from', $from->setTime(0, 0))
+ ->getQuery()
+ ->getSingleScalarResult();
+ }
+
/**
* The occurrences an "end this series" would remove.
*
diff --git a/src/Repository/ResourceRepository.php b/src/Repository/ResourceRepository.php
index a938129..b31f768 100644
--- a/src/Repository/ResourceRepository.php
+++ b/src/Repository/ResourceRepository.php
@@ -42,6 +42,23 @@ class ResourceRepository extends ServiceEntityRepository
->getResult();
}
+ /**
+ * Retired ones included, for the admin screen -- which is where they are reactivated.
+ *
+ * @return list
+ */
+ public function findAllByType(ResourceType $type): array
+ {
+ return $this->createQueryBuilder('r')
+ ->leftJoin('r.group', 'g')
+ ->addSelect('g')
+ ->andWhere('r.type = :type')
+ ->setParameter('type', $type)
+ ->orderBy('r.label', 'ASC')
+ ->getQuery()
+ ->getResult();
+ }
+
/**
* @return list
*/
diff --git a/templates/admin/_modal_resource.html.twig b/templates/admin/_modal_resource.html.twig
new file mode 100644
index 0000000..1c8a46f
--- /dev/null
+++ b/templates/admin/_modal_resource.html.twig
@@ -0,0 +1,41 @@
+{#
+ Creating and editing a desk or parking spot. Same double duty as the booking modal: the form
+ targets the modal itself, so an invalid submit (422) replaces it with the errors showing, and a
+ valid one answers with HX-Redirect back to the list.
+#}
+{% extends 'htmx_modal.html.twig' %}
+
+{% block title %}
+ {%- if resource.id -%}
+ {{ resource.label }} bearbeiten
+ {%- else -%}
+ {{ resource.type.label }} hinzufügen
+ {%- endif -%}
+{% endblock %}
+
+{% block content %}
+ {{ form_start(form, { attr: {
+ 'hx-post': form.vars.action,
+ 'hx-target': '#htmx-modal',
+ 'hx-swap': 'outerHTML',
+ } }) }}
+ {{ form_errors(form) }}
+
+
+ {{ form_row(form.label) }}
+ {{ form_row(form.group) }}
+ {% if form.active is defined %}
+ {{ form_row(form.active) }}
+ {% endif %}
+
+
+
+
+ Abbrechen
+
+
+ Speichern
+
+
+ {{ form_end(form) }}
+{% endblock %}
diff --git a/templates/admin/_modal_resource_group.html.twig b/templates/admin/_modal_resource_group.html.twig
new file mode 100644
index 0000000..01c1262
--- /dev/null
+++ b/templates/admin/_modal_resource_group.html.twig
@@ -0,0 +1,34 @@
+{# Creating and editing a room or area. See admin/_modal_resource.html.twig for how it closes. #}
+{% extends 'htmx_modal.html.twig' %}
+
+{% block title %}
+ {%- if group.id -%}
+ {{ group.label }} bearbeiten
+ {%- else -%}
+ {{ group.type.groupLabel }} hinzufügen
+ {%- endif -%}
+{% endblock %}
+
+{% block content %}
+ {{ form_start(form, { attr: {
+ 'hx-post': form.vars.action,
+ 'hx-target': '#htmx-modal',
+ 'hx-swap': 'outerHTML',
+ } }) }}
+ {{ form_errors(form) }}
+
+
+ {{ form_row(form.label) }}
+ {{ form_row(form.position) }}
+
+
+
+
+ Abbrechen
+
+
+ Speichern
+
+
+ {{ form_end(form) }}
+{% endblock %}
diff --git a/templates/admin/_modal_resource_group_delete.html.twig b/templates/admin/_modal_resource_group_delete.html.twig
new file mode 100644
index 0000000..5750fb1
--- /dev/null
+++ b/templates/admin/_modal_resource_group_delete.html.twig
@@ -0,0 +1,11 @@
+{% extends 'htmx_confirmation_modal.html.twig' %}
+
+{% block title %}{{ group.type.groupLabel }} löschen?{% endblock %}
+
+{% block content %}
+
+ {{ group.type.groupLabel }} {{ group.label }} wird gelöscht.
+
+{% endblock %}
+
+{% block button_confirm %}Löschen{% endblock %}
diff --git a/templates/admin/resources.html.twig b/templates/admin/resources.html.twig
new file mode 100644
index 0000000..14733c1
--- /dev/null
+++ b/templates/admin/resources.html.twig
@@ -0,0 +1,86 @@
+{#
+ Everything bookable, per type and room. Every write opens a modal via htmx and navigates back
+ here once saved -- the same shape as the sibling apps' admin lists.
+
+ Retired resources stay listed, muted, because this is the only place they can be reactivated.
+ Only an empty room offers "Löschen"; DeleteController refuses the rest regardless.
+#}
+{% extends 'layout.html.twig' %}
+
+{% block title %}Plätze & Räume · ephub{% endblock %}
+
+{% macro opener(url, label, variant = 'neutral') %}
+ {{ label }}
+{% endmacro %}
+
+{% macro resource_list(resources) %}
+ {% if resources is empty %}
+ Noch nichts angelegt.
+ {% else %}
+
+ {% for resource in resources %}
+
+
+ {{ resource.label }}
+ {% if not resource.active %}
+ nicht buchbar
+ {% endif %}
+
+ {{ _self.opener(path('app_admin_resource_edit', { id: resource.id }), 'Bearbeiten') }}
+
+ {% endfor %}
+
+ {% endif %}
+{% endmacro %}
+
+{% block content %}
+ ← Übersicht
+
+ Plätze & Räume
+ Was gebucht werden kann und wo es steht.
+
+ {% for section in sections %}
+ {% set type = section.type %}
+
+
+
+
{{ type.plural }}
+
+ {{ _self.opener(path('app_admin_resource_group_new', { type: type.value }), type.groupLabel ~ ' hinzufügen') }}
+ {{ _self.opener(path('app_admin_resource_new', { type: type.value }), type.label ~ ' hinzufügen', 'primary') }}
+
+
+
+ {% for entry in section.groups %}
+
+
+
+ {{ entry.group.label }}
+
+
+ {{ _self.opener(path('app_admin_resource_group_edit', { id: entry.group.id }), 'Bearbeiten') }}
+ {% if entry.resources is empty %}
+ {{ _self.opener(path('app_admin_resource_group_delete', { id: entry.group.id }), 'Löschen') }}
+ {% endif %}
+
+
+ {{ _self.resource_list(entry.resources) }}
+
+ {% endfor %}
+
+ {# Only when there is something to show, or when there are no groups at all -- then this
+ is simply the list, as for the car park. #}
+ {% if section.ungrouped is not empty or section.groups is empty %}
+
+ {% if section.groups is not empty %}
+
Ohne {{ type.groupLabel }}
+ {% endif %}
+ {{ _self.resource_list(section.ungrouped) }}
+
+ {% endif %}
+
+ {% endfor %}
+{% endblock %}
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig
index 745cfe3..2b05905 100644
--- a/templates/dashboard/index.html.twig
+++ b/templates/dashboard/index.html.twig
@@ -63,6 +63,17 @@
Buchungen aller Mitarbeitenden einsehen und ggf. freigeben.
+
+
+
+ {{ ux_icon('lucide:layout-grid', { class: 'h-6 w-6 shrink-0 text-primary' }) }}
+ Plätze & Räume
+
+
+ Schreibtische, Parkplätze und Räume anlegen und bearbeiten.
+
+
{% endif %}
diff --git a/tests/Controller/Admin/ResourceAdminControllerTest.php b/tests/Controller/Admin/ResourceAdminControllerTest.php
new file mode 100644
index 0000000..c23e6c8
--- /dev/null
+++ b/tests/Controller/Admin/ResourceAdminControllerTest.php
@@ -0,0 +1,322 @@
+client = self::createClient();
+ $this->client->disableReboot();
+ $this->em = self::getContainer()->get(EntityManagerInterface::class);
+ $this->em->getConnection()->beginTransaction();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->em->getConnection()->rollBack();
+ parent::tearDown();
+ }
+
+ public function testAnEmployeeCannotReachIt(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+
+ $this->client->request('GET', '/admin/resources');
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+
+ // Not as htmx: an htmx request gets the error modal instead of the bare status.
+ $this->client->request('GET', '/admin/resources/new/desk');
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+ }
+
+ public function testTheListShowsRoomsAndRetiredResources(): void
+ {
+ $room = $this->group('Raum Nord');
+ $this->resource('Schreibtisch 01', $room);
+ $this->resource('Schreibtisch 02', $room)->setActive(false);
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/resources');
+
+ self::assertResponseIsSuccessful();
+ $desks = $crawler->filter('[data-type="desk"]')->text();
+ self::assertStringContainsString('Raum Nord', $desks);
+ self::assertStringContainsString('Schreibtisch 01', $desks);
+ self::assertStringContainsString('Schreibtisch 02 nicht buchbar', preg_replace('/\s+/', ' ', $desks) ?? '');
+ }
+
+ public function testCreatingAResourceInARoom(): void
+ {
+ $room = $this->group('Raum Nord');
+ $this->client->loginUser($this->admin());
+
+ $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 13', 'group' => (string) $room->getId()]);
+
+ self::assertSame('/admin/resources', $response->headers->get('HX-Redirect'));
+ $created = $this->em->getRepository(Resource::class)->findOneBy(['label' => 'Schreibtisch 13']);
+ self::assertNotNull($created);
+ self::assertSame(ResourceType::DESK, $created->getType());
+ self::assertSame($room->getId(), $created->getGroup()?->getId());
+ self::assertTrue($created->isActive());
+ }
+
+ public function testADuplicateLabelIsRejectedInTheModal(): void
+ {
+ $this->resource('Schreibtisch 01');
+ $this->client->loginUser($this->admin());
+
+ $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 01']);
+
+ self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
+ self::assertStringContainsString('bereits vergeben', (string) $response->getContent());
+ }
+
+ /**
+ * The same label is fine for another type -- uniqueness is per type, like the database's.
+ */
+ public function testTheSameLabelMayExistForAnotherType(): void
+ {
+ $this->resource('A1');
+ $this->client->loginUser($this->admin());
+
+ $response = $this->submit('/admin/resources/new/parking', 'resource_form', ['label' => 'A1']);
+
+ self::assertSame('/admin/resources', $response->headers->get('HX-Redirect'));
+ }
+
+ /**
+ * The group choices are limited to the resource's type, so a parking area cannot be forced
+ * onto a desk by editing the request.
+ */
+ public function testARoomOfAnotherTypeIsRejected(): void
+ {
+ $area = $this->group('Tiefgarage', ResourceType::PARKING);
+ $this->client->loginUser($this->admin());
+
+ $response = $this->submit('/admin/resources/new/desk', 'resource_form', ['label' => 'Schreibtisch 13', 'group' => (string) $area->getId()]);
+
+ self::assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode());
+ self::assertNull($this->em->getRepository(Resource::class)->findOneBy(['label' => 'Schreibtisch 13']));
+ }
+
+ public function testAnUnknownTypeIsNotFound(): void
+ {
+ $this->client->loginUser($this->admin());
+
+ $this->client->request('GET', '/admin/resources/new/boat');
+
+ self::assertResponseStatusCodeSame(Response::HTTP_NOT_FOUND);
+ }
+
+ /**
+ * Retiring keeps the bookings and says how many are left, and the resource leaves the plan.
+ */
+ public function testRetiringAResourceWithUpcomingBookings(): void
+ {
+ $desk = $this->resource('Schreibtisch 01');
+ $this->em->persist(new Booking($this->user('bea@ep-reisen.de'), $desk, new \DateTimeImmutable('+1 day')));
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ // An unticked checkbox is simply absent from the submit.
+ $response = $this->submit(\sprintf('/admin/resources/%d/edit', $desk->getId()), 'resource_form', ['label' => 'Schreibtisch 01']);
+
+ self::assertSame('/admin/resources', $response->headers->get('HX-Redirect'));
+ $desk = $this->reload($desk);
+ self::assertFalse($desk->isActive());
+ self::assertCount(1, $this->em->getRepository(Booking::class)->findBy(['resource' => $desk]));
+
+ $this->client->request('GET', '/admin/resources');
+ self::assertStringContainsString('1 bestehende Buchungen ab heute bleiben erhalten', (string) $this->client->getResponse()->getContent());
+
+ $board = $this->client->request('GET', '/book/desk');
+ self::assertCount(0, $board->filter('[data-spot]'));
+ }
+
+ public function testReactivatingAResource(): void
+ {
+ $desk = $this->resource('Schreibtisch 01');
+ $desk->setActive(false);
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $this->submit(\sprintf('/admin/resources/%d/edit', $desk->getId()), 'resource_form', ['label' => 'Schreibtisch 01', 'active' => '1']);
+
+ self::assertTrue($this->reload($desk)->isActive());
+ }
+
+ public function testANewRoomIsPrefilledToComeLast(): void
+ {
+ $this->group('Raum Nord', position: 0);
+ $this->group('Raum Süd', position: 4);
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/resource-groups/new/desk', server: ['HTTP_HX-Request' => 'true']);
+
+ self::assertSame('5', $crawler->filter('#resource_group_form_position')->attr('value'));
+ }
+
+ public function testCreatingAndRenamingARoom(): void
+ {
+ $this->client->loginUser($this->admin());
+
+ $response = $this->submit('/admin/resource-groups/new/desk', 'resource_group_form', ['label' => 'Raum West', 'position' => '2']);
+ self::assertSame('/admin/resources', $response->headers->get('HX-Redirect'));
+
+ $room = $this->em->getRepository(ResourceGroup::class)->findOneBy(['label' => 'Raum West']);
+ self::assertNotNull($room);
+ self::assertSame(ResourceType::DESK, $room->getType());
+
+ $this->submit(\sprintf('/admin/resource-groups/%d/edit', $room->getId()), 'resource_group_form', ['label' => 'Raum Ost', 'position' => '1']);
+
+ $room = $this->reload($room);
+ self::assertSame('Raum Ost', $room->getLabel());
+ self::assertSame(1, $room->getPosition());
+ }
+
+ public function testDeletingAnEmptyRoom(): void
+ {
+ $room = $this->group('Raum Nord');
+ $id = $room->getId();
+ $this->client->loginUser($this->admin());
+ $uri = \sprintf('/admin/resource-groups/%d/delete', $id);
+
+ $this->client->request('POST', $uri, ['_token' => $this->confirmationToken($uri)], server: ['HTTP_HX-Request' => 'true']);
+
+ self::assertSame('/admin/resources', $this->client->getResponse()->headers->get('HX-Redirect'));
+ $this->em->clear();
+ self::assertNull($this->em->find(ResourceGroup::class, $id));
+ }
+
+ /**
+ * A retired resource still sits in the room, so it counts.
+ */
+ public function testARoomWithResourcesCannotBeDeleted(): void
+ {
+ $room = $this->group('Raum Nord');
+ $this->resource('Schreibtisch 01', $room)->setActive(false);
+ $this->em->flush();
+ $this->client->loginUser($this->admin());
+
+ $crawler = $this->client->request('GET', '/admin/resources');
+ self::assertCount(0, $crawler->filter(\sprintf('[hx-get="/admin/resource-groups/%d/delete"]', $room->getId())));
+
+ $this->client->request('POST', \sprintf('/admin/resource-groups/%d/delete', $room->getId()), ['_token' => 'whatever']);
+ self::assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
+ }
+
+ public function testTheDashboardLinksHereForAdminsOnly(): void
+ {
+ $this->client->loginUser($this->user('anna@ep-reisen.de'));
+ self::assertCount(0, $this->client->request('GET', '/')->filter('a[href="/admin/resources"]'));
+
+ $this->client->loginUser($this->admin());
+ self::assertCount(1, $this->client->request('GET', '/')->filter('a[href="/admin/resources"]'));
+ }
+
+ /**
+ * Opens the modal, then posts the given fields with its CSRF token -- the round trip a
+ * browser makes.
+ *
+ * @param array $fields
+ */
+ private function submit(string $uri, string $formName, array $fields): Response
+ {
+ $crawler = $this->client->request('GET', $uri, server: ['HTTP_HX-Request' => 'true']);
+ self::assertResponseIsSuccessful();
+
+ $fields['_token'] = $crawler->filter(\sprintf('#%s__token', $formName))->attr('value');
+ $this->client->request('POST', $uri, [$formName => $fields], server: ['HTTP_HX-Request' => 'true']);
+
+ return $this->client->getResponse();
+ }
+
+ 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'];
+ }
+
+ /**
+ * Each request resets the entity manager, so what a test holds is detached afterwards.
+ *
+ * @template T of Resource|ResourceGroup
+ *
+ * @param T $entity
+ *
+ * @return T
+ */
+ private function reload(Resource|ResourceGroup $entity): Resource|ResourceGroup
+ {
+ $fresh = $this->em->find($entity::class, $entity->getId());
+ self::assertNotNull($fresh);
+
+ return $fresh;
+ }
+
+ private function group(string $label, ResourceType $type = ResourceType::DESK, int $position = 0): ResourceGroup
+ {
+ $group = new ResourceGroup($type, $label, $position);
+ $this->em->persist($group);
+ $this->em->flush();
+
+ return $group;
+ }
+
+ private function resource(string $label, ?ResourceGroup $group = null): Resource
+ {
+ $resource = new Resource(ResourceType::DESK, $label);
+ $resource->setGroup($group);
+ $this->em->persist($resource);
+ $this->em->flush();
+
+ return $resource;
+ }
+
+ private function admin(): User
+ {
+ return $this->user('chef@ep-reisen.de', ['ROLE_ADMIN']);
+ }
+
+ /**
+ * @param list $roles
+ */
+ private function user(string $email, array $roles = ['ROLE_EMPLOYEE']): User
+ {
+ $existing = $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
+ if (null !== $existing) {
+ return $existing;
+ }
+
+ $user = new User($email, 'Test');
+ $user->setRoles($roles);
+ $this->em->persist($user);
+ $this->em->flush();
+
+ return $user;
+ }
+}