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 %} +
+ +
+ + +
+ {{ 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) }} +
+ +
+ + +
+ {{ 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') %} + +{% endmacro %} + +{% macro resource_list(resources) %} + {% if resources is empty %} +

Noch nichts angelegt.

+ {% else %} + + {% 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; + } +}