diff --git a/src/Controller/Admin/AccommodationBooking/FilterController.php b/src/Controller/Admin/AccommodationBooking/FilterController.php
new file mode 100644
index 0000000..0395158
--- /dev/null
+++ b/src/Controller/Admin/AccommodationBooking/FilterController.php
@@ -0,0 +1,54 @@
+isGranted('ROLE_GROUPS_ADMIN');
+ $user = $this->getUser();
+
+ $filterView = $this->createListFilterView(
+ $request,
+ AccommodationBookingFilterType::class,
+ AccommodationBookingFilterDto::defaults($seesAllBookings, $user instanceof User ? $user : null),
+ 'app_admin_accommodationbooking',
+ $this->filterOptions->formOptions($seesAllBookings),
+ );
+
+ return $this->render('admin/accommodation_booking/modal_filter.html.twig', [
+ 'form' => $filterView,
+ 'route' => 'app_admin_accommodationbooking',
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php
index e83d125..1b04ac0 100644
--- a/src/Controller/Admin/AccommodationBooking/IndexController.php
+++ b/src/Controller/Admin/AccommodationBooking/IndexController.php
@@ -4,6 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking;
+use App\Controller\Traits\ListFilterTrait;
+use App\Entity\User;
+use App\Form\Admin\Filter\AccommodationBookingFilterOptionsProvider;
+use App\Form\Admin\Filter\AccommodationBookingFilterType;
+use App\Form\Model\Filter\AccommodationBookingFilterDto;
use App\Repository\Groups\AccommodationBookingRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,8 +20,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class IndexController extends AbstractController
{
+ use ListFilterTrait;
+
public function __construct(
private readonly AccommodationBookingRepository $bookingRepository,
+ private readonly AccommodationBookingFilterOptionsProvider $filterOptions,
private readonly PaginatorInterface $paginator,
) {
}
@@ -24,21 +32,33 @@ class IndexController extends AbstractController
#[Route('/admin/accommodation-booking', name: 'app_admin_accommodationbooking')]
public function index(Request $request): Response
{
- $today = new \DateTimeImmutable('today');
+ // ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both.
+ $seesAllBookings = $this->isGranted('ROLE_GROUPS_ADMIN');
+ $user = $this->getUser();
+ $user = $user instanceof User ? $user : null;
- $qb = $this
- ->bookingRepository
- ->createQueryBuilder('booking')
- ->select('booking', 'managed_by')
- ->leftJoin('booking.accommodation', 'accommodation')
- ->leftJoin('booking.managedBy', 'managed_by')
- ->addSelect('accommodation')
- ->where('booking.dateTo >= :today')
- ->setParameter('today', $today)
- ;
+ // The form writes into $filter, so after this call it holds either the defaults or
+ // whatever the query string asked for.
+ $filter = AccommodationBookingFilterDto::defaults($seesAllBookings, $user);
+
+ $filterView = $this->createListFilterView(
+ $request,
+ AccommodationBookingFilterType::class,
+ $filter,
+ 'app_admin_accommodationbooking',
+ $this->filterOptions->formOptions($seesAllBookings),
+ );
+
+ // The manager fields are not part of the form for this role, but the scope is pinned
+ // here as well so that a hand-written query parameter cannot widen it either.
+ if (!$seesAllBookings) {
+ $filter->managedBy = $user;
+ $filter->unassigned = false;
+ $filter->managedByLocked = true;
+ }
$pagination = $this->paginator->paginate(
- $qb,
+ $this->bookingRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
@@ -49,6 +69,8 @@ class IndexController extends AbstractController
return $this->render('admin/accommodation_booking/index.html.twig', [
'pagination' => $pagination,
+ 'filter' => $filter,
+ 'filter_form' => $filterView,
]);
}
}
diff --git a/src/Controller/Admin/BookingEditDraft/FilterController.php b/src/Controller/Admin/BookingEditDraft/FilterController.php
new file mode 100644
index 0000000..35c95a5
--- /dev/null
+++ b/src/Controller/Admin/BookingEditDraft/FilterController.php
@@ -0,0 +1,43 @@
+createListFilterView(
+ $request,
+ BookingEditDraftFilterType::class,
+ new BookingEditDraftFilterDto(),
+ 'app_admin_bookingeditdraft',
+ ['users' => $this->bookingEditDraftRepository->findDraftOwners()],
+ );
+
+ return $this->render('admin/_modal_filter.html.twig', [
+ 'form' => $filterView,
+ 'route' => 'app_admin_bookingeditdraft',
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/BookingEditDraft/IndexController.php b/src/Controller/Admin/BookingEditDraft/IndexController.php
index 08a36b0..63d221f 100644
--- a/src/Controller/Admin/BookingEditDraft/IndexController.php
+++ b/src/Controller/Admin/BookingEditDraft/IndexController.php
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Controller\Admin\BookingEditDraft;
+use App\Controller\Traits\ListFilterTrait;
use App\Controller\Traits\ReturnUrlTrait;
+use App\Form\Admin\Filter\BookingEditDraftFilterType;
+use App\Form\Model\Filter\BookingEditDraftFilterDto;
use App\Repository\BookingEditDraftRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -16,6 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_ADMIN')]
class IndexController extends AbstractController
{
+ use ListFilterTrait;
use ReturnUrlTrait;
public function __construct(
@@ -27,14 +31,18 @@ class IndexController extends AbstractController
#[Route('/admin/booking-edit-draft', name: 'app_admin_bookingeditdraft')]
public function index(Request $request): Response
{
- $qb = $this
- ->bookingEditDraftRepository
- ->createQueryBuilder('booking_edit_draft')
- ->leftJoin('booking_edit_draft.user', 'user')
- ;
+ $filter = new BookingEditDraftFilterDto();
+
+ $filterView = $this->createListFilterView(
+ $request,
+ BookingEditDraftFilterType::class,
+ $filter,
+ 'app_admin_bookingeditdraft',
+ ['users' => $this->bookingEditDraftRepository->findDraftOwners()],
+ );
$pagination = $this->paginator->paginate(
- $qb,
+ $this->bookingEditDraftRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 20),
[
@@ -45,6 +53,8 @@ class IndexController extends AbstractController
return $this->render('admin/booking_edit_draft/index.html.twig', [
'pagination' => $pagination,
+ 'filter' => $filter,
+ 'filter_form' => $filterView,
]);
}
}
diff --git a/src/Controller/Admin/Log/FilterController.php b/src/Controller/Admin/Log/FilterController.php
new file mode 100644
index 0000000..3dc92c2
--- /dev/null
+++ b/src/Controller/Admin/Log/FilterController.php
@@ -0,0 +1,43 @@
+createListFilterView(
+ $request,
+ LogEntryFilterType::class,
+ new LogEntryFilterDto(),
+ 'app_admin_log',
+ ['channels' => $this->logEntryRepository->findChannels()],
+ );
+
+ return $this->render('admin/_modal_filter.html.twig', [
+ 'form' => $filterView,
+ 'route' => 'app_admin_log',
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/Log/IndexController.php b/src/Controller/Admin/Log/IndexController.php
index b1c66da..5733768 100644
--- a/src/Controller/Admin/Log/IndexController.php
+++ b/src/Controller/Admin/Log/IndexController.php
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Admin\Log;
+use App\Controller\Traits\ListFilterTrait;
+use App\Form\Admin\Filter\LogEntryFilterType;
+use App\Form\Model\Filter\LogEntryFilterDto;
use App\Repository\LogEntryRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,6 +18,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
+ use ListFilterTrait;
+
public function __construct(
private readonly LogEntryRepository $logEntryRepository,
private readonly PaginatorInterface $paginator,
@@ -24,13 +29,18 @@ class IndexController extends AbstractController
#[Route('/admin/log', name: 'app_admin_log')]
public function index(Request $request): Response
{
- $qb = $this
- ->logEntryRepository
- ->createQueryBuilder('log_entry')
- ;
+ $filter = new LogEntryFilterDto();
+
+ $filterView = $this->createListFilterView(
+ $request,
+ LogEntryFilterType::class,
+ $filter,
+ 'app_admin_log',
+ ['channels' => $this->logEntryRepository->findChannels()],
+ );
$pagination = $this->paginator->paginate(
- $qb,
+ $this->logEntryRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -41,6 +51,8 @@ class IndexController extends AbstractController
return $this->render('admin/log/index.html.twig', [
'pagination' => $pagination,
+ 'filter' => $filter,
+ 'filter_form' => $filterView,
]);
}
}
diff --git a/src/Controller/Admin/User/FilterController.php b/src/Controller/Admin/User/FilterController.php
new file mode 100644
index 0000000..c466f47
--- /dev/null
+++ b/src/Controller/Admin/User/FilterController.php
@@ -0,0 +1,37 @@
+createListFilterView(
+ $request,
+ UserFilterType::class,
+ new UserFilterDto(),
+ 'app_admin_user',
+ ['roles' => UserFilterType::ROLES],
+ );
+
+ return $this->render('admin/_modal_filter.html.twig', [
+ 'form' => $filterView,
+ 'route' => 'app_admin_user',
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/User/IndexController.php b/src/Controller/Admin/User/IndexController.php
index 1f6f212..9f56476 100644
--- a/src/Controller/Admin/User/IndexController.php
+++ b/src/Controller/Admin/User/IndexController.php
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Controller\Admin\User;
+use App\Controller\Traits\ListFilterTrait;
+use App\Form\Admin\Filter\UserFilterType;
+use App\Form\Model\Filter\UserFilterDto;
use App\Repository\UserRepository;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,6 +18,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class IndexController extends AbstractController
{
+ use ListFilterTrait;
+
public function __construct(
private readonly UserRepository $userRepository,
private readonly PaginatorInterface $paginator,
@@ -24,13 +29,18 @@ class IndexController extends AbstractController
#[Route('/admin/user', name: 'app_admin_user')]
public function index(Request $request): Response
{
- $qb = $this
- ->userRepository
- ->createQueryBuilder('user')
- ;
+ $filter = new UserFilterDto();
+
+ $filterView = $this->createListFilterView(
+ $request,
+ UserFilterType::class,
+ $filter,
+ 'app_admin_user',
+ ['roles' => UserFilterType::ROLES],
+ );
$pagination = $this->paginator->paginate(
- $qb,
+ $this->userRepository->createFilteredQueryBuilder($filter),
$request->query->getInt('page', 1),
$request->query->getInt('limit', 50),
[
@@ -41,6 +51,8 @@ class IndexController extends AbstractController
return $this->render('admin/user/index.html.twig', [
'pagination' => $pagination,
+ 'filter' => $filter,
+ 'filter_form' => $filterView,
]);
}
}
diff --git a/src/Controller/Traits/ListFilterTrait.php b/src/Controller/Traits/ListFilterTrait.php
new file mode 100644
index 0000000..98ed1b8
--- /dev/null
+++ b/src/Controller/Traits/ListFilterTrait.php
@@ -0,0 +1,54 @@
+ $options
+ */
+ protected function createListFilterView(
+ Request $request,
+ string $formType,
+ AbstractListFilterDto $defaults,
+ string $listRoute,
+ array $options = [],
+ ): FormView {
+ $listUrl = $this->generateUrl($listRoute);
+
+ // The modal lives inside
, so swapping the body's content both refreshes the list
+ // and closes the modal in a single request — no OOB swap and no redirect round trip.
+ $form = $this->createForm($formType, $defaults, $options + [
+ 'action' => $listUrl,
+ 'hx_get' => $listUrl,
+ 'hx_target' => 'body',
+ 'hx_swap' => 'innerHTML',
+ 'hx_push_url' => true,
+ ]);
+
+ if ($request->query->has(AbstractListFilterDto::MARKER)) {
+ $form->handleRequest($request);
+ }
+
+ return $form->createView();
+ }
+}
diff --git a/src/Form/Admin/Filter/AbstractListFilterType.php b/src/Form/Admin/Filter/AbstractListFilterType.php
new file mode 100644
index 0000000..a47da4c
--- /dev/null
+++ b/src/Form/Admin/Filter/AbstractListFilterType.php
@@ -0,0 +1,113 @@
+
+ */
+abstract class AbstractListFilterType extends AbstractType
+{
+ /**
+ * @param FormBuilderInterface $builder
+ */
+ final public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $fields = [
+ // The marker has to be a field of this form, not just a query parameter: an unnamed
+ // form is only auto-submitted when the query string contains at least one of its own
+ // fields. Without it, removing the last active filter would leave `?f=1` alone, the
+ // form would never bind, and the list would snap back to its defaults instead of
+ // widening.
+ AbstractListFilterDto::MARKER => [HiddenType::class, [
+ 'mapped' => false,
+ 'data' => '1',
+ ]],
+ 'q' => [SearchType::class, [
+ 'label' => 'Suche',
+ 'required' => false,
+ 'attr' => ['placeholder' => $this->searchPlaceholder()],
+ ]],
+ 'dateFrom' => [DateType::class, [
+ 'label' => $this->dateFromLabel(),
+ 'widget' => 'single_text',
+ 'input' => 'datetime_immutable',
+ 'required' => false,
+ ]],
+ 'dateTo' => [DateType::class, [
+ 'label' => $this->dateToLabel(),
+ 'widget' => 'single_text',
+ 'input' => 'datetime_immutable',
+ 'required' => false,
+ ]],
+ ];
+
+ foreach ($fields + $this->filterFields($options) as $name => [$type, $fieldOptions]) {
+ $builder->add($name, $type, $fieldOptions);
+ }
+ }
+
+ /**
+ * The fields that only make sense for one entity, as name => [type, options].
+ *
+ * Declaring them instead of adding them keeps the search term and the date range first in
+ * every filter, and keeps buildForm() — which is final for that reason — the only place a
+ * form builder is touched.
+ *
+ * @param array $options
+ *
+ * @return array}>
+ */
+ protected function filterFields(array $options): array
+ {
+ return [];
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'method' => 'GET',
+ 'csrf_protection' => false,
+ 'allow_extra_fields' => true,
+ ]);
+ }
+
+ public function getBlockPrefix(): string
+ {
+ return '';
+ }
+
+ protected function searchPlaceholder(): string
+ {
+ return 'Suchen…';
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Zeitraum ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Zeitraum bis';
+ }
+}
diff --git a/src/Form/Admin/Filter/AccommodationBookingFilterOptionsProvider.php b/src/Form/Admin/Filter/AccommodationBookingFilterOptionsProvider.php
new file mode 100644
index 0000000..e3545ef
--- /dev/null
+++ b/src/Form/Admin/Filter/AccommodationBookingFilterOptionsProvider.php
@@ -0,0 +1,64 @@
+
+ */
+ public function formOptions(bool $seesAllBookings): array
+ {
+ return [
+ 'can_filter_by_manager' => $seesAllBookings,
+ 'managers' => $seesAllBookings ? $this->managers() : [],
+ 'accommodations' => $this->accommodationRepository->findBy([], ['name' => 'ASC']),
+ ];
+ }
+
+ /**
+ * Everyone worth filtering by: the staff a booking can be assigned to, plus whoever is
+ * currently assigned to one.
+ *
+ * The second half matters because the two lists drift apart — somebody assigned last season
+ * may have lost the groups role since, and their bookings would otherwise be impossible to
+ * find. The edit form keeps the current value in its choices for the same reason.
+ *
+ * @return User[]
+ */
+ private function managers(): array
+ {
+ $managers = [];
+
+ foreach ([...$this->userRepository->findGroupsStaff(), ...$this->bookingRepository->findAssignedManagers()] as $manager) {
+ $managers[(int) $manager->getId()] = $manager;
+ }
+
+ uasort($managers, static fn (User $a, User $b) => strcasecmp((string) $a->getEmail(), (string) $b->getEmail()));
+
+ return array_values($managers);
+ }
+}
diff --git a/src/Form/Admin/Filter/AccommodationBookingFilterType.php b/src/Form/Admin/Filter/AccommodationBookingFilterType.php
new file mode 100644
index 0000000..84749e0
--- /dev/null
+++ b/src/Form/Admin/Filter/AccommodationBookingFilterType.php
@@ -0,0 +1,105 @@
+
+ */
+class AccommodationBookingFilterType extends AbstractListFilterType
+{
+ protected function filterFields(array $options): array
+ {
+ $fields = [
+ 'status' => [EnumType::class, [
+ 'label' => 'Status',
+ 'class' => AccommodationBookingStatus::class,
+ 'choice_label' => static fn (AccommodationBookingStatus $status) => $status->label(),
+ 'multiple' => true,
+ 'expanded' => true,
+ 'required' => false,
+ ]],
+ 'type' => [EnumType::class, [
+ 'label' => 'Art',
+ 'class' => AccommodationBookingType::class,
+ 'choice_label' => static fn (AccommodationBookingType $type) => $type->label(),
+ 'multiple' => true,
+ 'expanded' => true,
+ 'required' => false,
+ ]],
+ 'accommodation' => [EntityType::class, [
+ 'label' => 'Gruppenhaus',
+ 'class' => Accommodation::class,
+ 'choices' => $options['accommodations'],
+ 'choice_label' => 'name',
+ 'placeholder' => 'alle',
+ 'required' => false,
+ ]],
+ ];
+
+ // Mirrors the edit form: only group admins get to look at other people's bookings, so
+ // for everyone else the fields simply do not exist and the scope is pinned server-side.
+ if (true === $options['can_filter_by_manager']) {
+ // Triage does not depend on there being anyone to choose from, so this stays even
+ // when nobody carries a groups role yet.
+ $fields['unassigned'] = [CheckboxType::class, [
+ 'label' => 'nur ohne Zuordnung',
+ 'required' => false,
+ ]];
+
+ if ([] !== $options['managers']) {
+ $fields['managedBy'] = [EntityType::class, [
+ 'label' => 'Betreuer:in',
+ 'class' => User::class,
+ 'choices' => $options['managers'],
+ 'choice_label' => 'email',
+ 'placeholder' => 'alle',
+ 'required' => false,
+ ]];
+ }
+ }
+
+ return $fields;
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+
+ $resolver->setDefaults([
+ 'data_class' => AccommodationBookingFilterDto::class,
+ 'can_filter_by_manager' => false,
+ 'managers' => [],
+ 'accommodations' => [],
+ ]);
+ $resolver->setAllowedTypes('can_filter_by_manager', 'bool');
+ $resolver->setAllowedTypes('managers', User::class.'[]');
+ $resolver->setAllowedTypes('accommodations', Accommodation::class.'[]');
+ }
+
+ protected function searchPlaceholder(): string
+ {
+ return 'Gruppe, Name, Ort, E-Mail…';
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Aufenthalt ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Aufenthalt bis';
+ }
+}
diff --git a/src/Form/Admin/Filter/BookingEditDraftFilterType.php b/src/Form/Admin/Filter/BookingEditDraftFilterType.php
new file mode 100644
index 0000000..38c8914
--- /dev/null
+++ b/src/Form/Admin/Filter/BookingEditDraftFilterType.php
@@ -0,0 +1,56 @@
+
+ */
+class BookingEditDraftFilterType extends AbstractListFilterType
+{
+ protected function filterFields(array $options): array
+ {
+ return [
+ 'user' => [EntityType::class, [
+ 'label' => 'Kundenaccount',
+ 'class' => User::class,
+ 'choices' => $options['users'],
+ 'choice_label' => 'email',
+ 'placeholder' => 'alle',
+ 'required' => false,
+ ]],
+ ];
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+
+ $resolver->setDefaults([
+ 'data_class' => BookingEditDraftFilterDto::class,
+ 'users' => [],
+ ]);
+ $resolver->setAllowedTypes('users', User::class.'[]');
+ }
+
+ protected function searchPlaceholder(): string
+ {
+ return 'Vorgang, E-Mail…';
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Erstellt ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Erstellt bis';
+ }
+}
diff --git a/src/Form/Admin/Filter/LogEntryFilterType.php b/src/Form/Admin/Filter/LogEntryFilterType.php
new file mode 100644
index 0000000..904be90
--- /dev/null
+++ b/src/Form/Admin/Filter/LogEntryFilterType.php
@@ -0,0 +1,53 @@
+
+ */
+class LogEntryFilterType extends AbstractListFilterType
+{
+ protected function filterFields(array $options): array
+ {
+ return [
+ 'channel' => [ChoiceType::class, [
+ 'label' => 'Kanal',
+ 'choices' => array_combine($options['channels'], $options['channels']),
+ 'placeholder' => 'alle',
+ 'required' => false,
+ ]],
+ ];
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+
+ $resolver->setDefaults([
+ 'data_class' => LogEntryFilterDto::class,
+ 'channels' => [],
+ ]);
+ $resolver->setAllowedTypes('channels', 'string[]');
+ }
+
+ protected function searchPlaceholder(): string
+ {
+ return 'Meldung, Fehlercode…';
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Datum ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Datum bis';
+ }
+}
diff --git a/src/Form/Admin/Filter/UserFilterType.php b/src/Form/Admin/Filter/UserFilterType.php
new file mode 100644
index 0000000..76eac31
--- /dev/null
+++ b/src/Form/Admin/Filter/UserFilterType.php
@@ -0,0 +1,69 @@
+
+ */
+class UserFilterType extends AbstractListFilterType
+{
+ /**
+ * The roles that are actually assigned to accounts. ROLE_USER is left out because every
+ * account has it implicitly and filtering by it would match everything.
+ *
+ * @var string[]
+ */
+ public const ROLES = [
+ 'ROLE_ADMIN',
+ 'ROLE_MANAGER',
+ 'ROLE_TEAMER',
+ 'ROLE_CUSTOMER',
+ 'ROLE_HOUSE_MANAGER',
+ 'ROLE_GROUPS_ADMIN',
+ 'ROLE_GROUPS_MANAGER',
+ ];
+
+ protected function filterFields(array $options): array
+ {
+ return [
+ 'role' => [ChoiceType::class, [
+ 'label' => 'Rolle',
+ 'choices' => array_combine($options['roles'], $options['roles']),
+ 'placeholder' => 'alle',
+ 'required' => false,
+ ]],
+ ];
+ }
+
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ parent::configureOptions($resolver);
+
+ $resolver->setDefaults([
+ 'data_class' => UserFilterDto::class,
+ 'roles' => [],
+ ]);
+ $resolver->setAllowedTypes('roles', 'string[]');
+ }
+
+ protected function searchPlaceholder(): string
+ {
+ return 'E-Mail, Adress-Id, Personen-Id…';
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Letzter Login ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Letzter Login bis';
+ }
+}
diff --git a/src/Form/Admin/Groups/AccommodationBookingCreateType.php b/src/Form/Admin/Groups/AccommodationBookingCreateType.php
index 43742bd..23ee17c 100644
--- a/src/Form/Admin/Groups/AccommodationBookingCreateType.php
+++ b/src/Form/Admin/Groups/AccommodationBookingCreateType.php
@@ -54,7 +54,6 @@ class AccommodationBookingCreateType extends AbstractType
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Open,
- AccommodationBookingStatus::Accepted,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])
diff --git a/src/Form/Extension/ModalSubmitExtension.php b/src/Form/Extension/ModalSubmitExtension.php
index fe61f93..d7be17a 100644
--- a/src/Form/Extension/ModalSubmitExtension.php
+++ b/src/Form/Extension/ModalSubmitExtension.php
@@ -14,22 +14,36 @@ class ModalSubmitExtension extends AbstractTypeExtension
{
$resolver->setDefaults([
'hx_post' => null,
+ 'hx_get' => null,
'hx_target' => '#htmx-modal',
'hx_swap' => 'outerHTML',
+ 'hx_push_url' => false,
]);
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
- if (null !== $options['hx_post']) {
- $attr = [
- 'hx-post' => $options['hx_post'],
- 'hx-target' => $options['hx_target'],
- 'hx-swap' => $options['hx_swap'],
- 'hx-indicator' => '#htmx-modal-indicator',
- ];
- $view->vars['attr'] = array_merge($view->vars['attr'], $attr);
+ // hx_post keeps the form inside the modal (so validation errors re-render in place);
+ // hx_get is for read-only forms such as list filters, which navigate the list instead
+ // and therefore usually target the body and push the resulting URL.
+ $method = null !== $options['hx_post'] ? 'hx-post' : (null !== $options['hx_get'] ? 'hx-get' : null);
+
+ if (null === $method) {
+ return;
}
+
+ $attr = [
+ $method => $options['hx-post' === $method ? 'hx_post' : 'hx_get'],
+ 'hx-target' => $options['hx_target'],
+ 'hx-swap' => $options['hx_swap'],
+ 'hx-indicator' => '#htmx-modal-indicator',
+ ];
+
+ if (false !== $options['hx_push_url']) {
+ $attr['hx-push-url'] = true === $options['hx_push_url'] ? 'true' : $options['hx_push_url'];
+ }
+
+ $view->vars['attr'] = array_merge($view->vars['attr'], $attr);
}
public static function getExtendedTypes(): iterable
diff --git a/src/Form/Model/Filter/AbstractListFilterDto.php b/src/Form/Model/Filter/AbstractListFilterDto.php
new file mode 100644
index 0000000..b7ecaf8
--- /dev/null
+++ b/src/Form/Model/Filter/AbstractListFilterDto.php
@@ -0,0 +1,83 @@
+q);
+
+ return '' === $term ? null : $term;
+ }
+
+ /**
+ * @return ListFilterChip[]
+ */
+ public function activeFilters(): array
+ {
+ $chips = [];
+
+ if (null !== $this->searchTerm()) {
+ $chips[] = new ListFilterChip('Suche', (string) $this->searchTerm(), ['q']);
+ }
+
+ if (null !== $this->dateFrom) {
+ $chips[] = new ListFilterChip($this->dateFromLabel(), $this->dateFrom->format('d.m.Y'), ['dateFrom']);
+ }
+
+ if (null !== $this->dateTo) {
+ $chips[] = new ListFilterChip($this->dateToLabel(), $this->dateTo->format('d.m.Y'), ['dateTo']);
+ }
+
+ return $chips;
+ }
+
+ public function activeCount(): int
+ {
+ return \count($this->activeFilters());
+ }
+
+ public function isActive(): bool
+ {
+ return [] !== $this->activeFilters();
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Zeitraum ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Zeitraum bis';
+ }
+}
diff --git a/src/Form/Model/Filter/AccommodationBookingFilterDto.php b/src/Form/Model/Filter/AccommodationBookingFilterDto.php
new file mode 100644
index 0000000..aa321cd
--- /dev/null
+++ b/src/Form/Model/Filter/AccommodationBookingFilterDto.php
@@ -0,0 +1,105 @@
+status = [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open];
+ $filter->dateFrom = new \DateTimeImmutable('today');
+
+ if (!$seesAllBookings) {
+ $filter->managedBy = $currentUser;
+ $filter->managedByLocked = true;
+ }
+
+ return $filter;
+ }
+
+ public function activeFilters(): array
+ {
+ $chips = parent::activeFilters();
+
+ if ([] !== $this->status) {
+ $chips[] = new ListFilterChip(
+ 'Status',
+ implode(', ', array_map(static fn (AccommodationBookingStatus $s) => $s->label(), $this->status)),
+ ['status'],
+ );
+ }
+
+ if ([] !== $this->type) {
+ $chips[] = new ListFilterChip(
+ 'Art',
+ implode(', ', array_map(static fn (AccommodationBookingType $t) => $t->label(), $this->type)),
+ ['type'],
+ );
+ }
+
+ if ($this->unassigned) {
+ $chips[] = new ListFilterChip('Betreuer:in', 'keine Zuordnung', ['unassigned']);
+ } elseif (null !== $this->managedBy) {
+ $chips[] = new ListFilterChip(
+ 'Betreuer:in',
+ (string) $this->managedBy->getEmail(),
+ $this->managedByLocked ? [] : ['managedBy'],
+ );
+ }
+
+ if (null !== $this->accommodation) {
+ $chips[] = new ListFilterChip('Gruppenhaus', (string) $this->accommodation->getName(), ['accommodation']);
+ }
+
+ return $chips;
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Aufenthalt ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Aufenthalt bis';
+ }
+}
diff --git a/src/Form/Model/Filter/BookingEditDraftFilterDto.php b/src/Form/Model/Filter/BookingEditDraftFilterDto.php
new file mode 100644
index 0000000..e0620a9
--- /dev/null
+++ b/src/Form/Model/Filter/BookingEditDraftFilterDto.php
@@ -0,0 +1,34 @@
+user) {
+ $chips[] = new ListFilterChip('Kundenaccount', (string) $this->user->getEmail(), ['user']);
+ }
+
+ return $chips;
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Erstellt ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Erstellt bis';
+ }
+}
diff --git a/src/Form/Model/Filter/LogEntryFilterDto.php b/src/Form/Model/Filter/LogEntryFilterDto.php
new file mode 100644
index 0000000..6ad2c29
--- /dev/null
+++ b/src/Form/Model/Filter/LogEntryFilterDto.php
@@ -0,0 +1,33 @@
+channel && '' !== $this->channel) {
+ $chips[] = new ListFilterChip('Kanal', $this->channel, ['channel']);
+ }
+
+ return $chips;
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Datum ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Datum bis';
+ }
+}
diff --git a/src/Form/Model/Filter/UserFilterDto.php b/src/Form/Model/Filter/UserFilterDto.php
new file mode 100644
index 0000000..94e405f
--- /dev/null
+++ b/src/Form/Model/Filter/UserFilterDto.php
@@ -0,0 +1,33 @@
+role && '' !== $this->role) {
+ $chips[] = new ListFilterChip('Rolle', $this->role, ['role']);
+ }
+
+ return $chips;
+ }
+
+ protected function dateFromLabel(): string
+ {
+ return 'Letzter Login ab';
+ }
+
+ protected function dateToLabel(): string
+ {
+ return 'Letzter Login bis';
+ }
+}
diff --git a/src/Model/ListFilterChip.php b/src/Model/ListFilterChip.php
new file mode 100644
index 0000000..2a07190
--- /dev/null
+++ b/src/Model/ListFilterChip.php
@@ -0,0 +1,31 @@
+removeKeys;
+ }
+}
diff --git a/src/Repository/BookingEditDraftRepository.php b/src/Repository/BookingEditDraftRepository.php
index 156ee44..6862041 100644
--- a/src/Repository/BookingEditDraftRepository.php
+++ b/src/Repository/BookingEditDraftRepository.php
@@ -6,9 +6,13 @@ namespace App\Repository;
use App\Entity\BookingEditDraft;
use App\Entity\User;
+use App\Form\Model\Filter\BookingEditDraftFilterDto;
+use App\Repository\Filter\AppliesListFiltersTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\Query\Parameter;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -16,11 +20,59 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class BookingEditDraftRepository extends ServiceEntityRepository
{
+ use AppliesListFiltersTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookingEditDraft::class);
}
+ public function createFilteredQueryBuilder(BookingEditDraftFilterDto $filter): QueryBuilder
+ {
+ $qb = $this
+ ->createQueryBuilder('booking_edit_draft')
+ ->leftJoin('booking_edit_draft.user', 'user')
+ ;
+
+ $this->applySearchTerm($qb, $filter->searchTerm(), [
+ 'user.email',
+ 'booking_edit_draft.bookingNumber',
+ 'booking_edit_draft.bookingId',
+ ]);
+
+ $this->applyDateWithin($qb, 'booking_edit_draft.createdAt', $filter->dateFrom, $filter->dateTo);
+
+ if (null !== $filter->user) {
+ $qb->andWhere('booking_edit_draft.user = :user')->setParameter('user', $filter->user);
+ }
+
+ return $qb;
+ }
+
+ /**
+ * The accounts that actually have a draft — the full user table would be an unusable
+ * choice list and most of it could never match.
+ *
+ * @return User[]
+ */
+ public function findDraftOwners(): array
+ {
+ // Rooted at the user rather than the draft: DQL cannot select a joined entity on its own.
+ /** @var User[] $users */
+ $users = $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('user')
+ ->distinct()
+ ->from(User::class, 'user')
+ ->innerJoin(BookingEditDraft::class, 'draft', Join::ON, 'draft.user = user')
+ ->orderBy('user.email', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $users;
+ }
+
/**
* Finds a draft for a specific user and booking combination.
*
diff --git a/src/Repository/Filter/AppliesListFiltersTrait.php b/src/Repository/Filter/AppliesListFiltersTrait.php
new file mode 100644
index 0000000..498dc1d
--- /dev/null
+++ b/src/Repository/Filter/AppliesListFiltersTrait.php
@@ -0,0 +1,94 @@
+ $token) {
+ $parameter = 'searchTerm'.$index;
+ $matches = [];
+
+ foreach ($fields as $field) {
+ $matches[] = sprintf('%s LIKE :%s', $field, $parameter);
+ }
+
+ $qb
+ ->andWhere($qb->expr()->orX(...$matches))
+ // Backslash is MySQL's default LIKE escape character, so wildcards a user typed
+ // stay literal without needing an explicit ESCAPE clause.
+ ->setParameter($parameter, '%'.addcslashes($token, '%_\\').'%')
+ ;
+ }
+ }
+
+ /**
+ * Keeps rows whose own period overlaps the filtered period at all.
+ *
+ * A range of "from today" therefore means "everything not yet over", which is the reading
+ * staff expect from a list of stays.
+ */
+ protected function applyDateOverlap(
+ QueryBuilder $qb,
+ string $fromField,
+ string $toField,
+ ?\DateTimeImmutable $from,
+ ?\DateTimeImmutable $to,
+ ): void {
+ if (null !== $from) {
+ $qb->andWhere($toField.' >= :filterDateFrom')->setParameter('filterDateFrom', $from);
+ }
+
+ if (null !== $to) {
+ $qb->andWhere($fromField.' <= :filterDateTo')->setParameter('filterDateTo', $to);
+ }
+ }
+
+ /**
+ * Keeps rows whose single date falls inside the filtered period, both ends inclusive.
+ *
+ * The upper bound is compared against the start of the following day so that a datetime
+ * column does not silently drop everything that happened after midnight on the last day.
+ */
+ protected function applyDateWithin(
+ QueryBuilder $qb,
+ string $field,
+ ?\DateTimeImmutable $from,
+ ?\DateTimeImmutable $to,
+ ): void {
+ if (null !== $from) {
+ $qb->andWhere($field.' >= :filterDateFrom')->setParameter('filterDateFrom', $from);
+ }
+
+ if (null !== $to) {
+ $qb
+ ->andWhere($field.' < :filterDateTo')
+ ->setParameter('filterDateTo', $to->modify('+1 day'))
+ ;
+ }
+ }
+}
diff --git a/src/Repository/Groups/AccommodationBookingRepository.php b/src/Repository/Groups/AccommodationBookingRepository.php
index 1d218ac..8722c8f 100644
--- a/src/Repository/Groups/AccommodationBookingRepository.php
+++ b/src/Repository/Groups/AccommodationBookingRepository.php
@@ -5,7 +5,12 @@ declare(strict_types=1);
namespace App\Repository\Groups;
use App\Entity\Groups\AccommodationBooking;
+use App\Entity\User;
+use App\Form\Model\Filter\AccommodationBookingFilterDto;
+use App\Repository\Filter\AppliesListFiltersTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\Query\Expr\Join;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -13,8 +18,86 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class AccommodationBookingRepository extends ServiceEntityRepository
{
+ use AppliesListFiltersTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AccommodationBooking::class);
}
+
+ /**
+ * The people who actually look after a booking right now.
+ *
+ * Assignability and responsibility are not the same list: somebody can be assigned and
+ * later lose the groups role, and their bookings still have to be findable. Rooted at the
+ * user because DQL cannot select a joined entity on its own.
+ *
+ * @return User[]
+ */
+ public function findAssignedManagers(): array
+ {
+ /** @var User[] $managers */
+ $managers = $this->getEntityManager()
+ ->createQueryBuilder()
+ ->select('user')
+ ->distinct()
+ ->from(User::class, 'user')
+ ->innerJoin(AccommodationBooking::class, 'booking', Join::ON, 'booking.managedBy = user')
+ ->orderBy('user.email', 'ASC')
+ ->getQuery()
+ ->getResult()
+ ;
+
+ return $managers;
+ }
+
+ /**
+ * The admin list query. Returns the builder rather than the result so the paginator can
+ * still add its own sorting and limits on top.
+ */
+ public function createFilteredQueryBuilder(AccommodationBookingFilterDto $filter): QueryBuilder
+ {
+ $qb = $this
+ ->createQueryBuilder('booking')
+ ->select('booking', 'managed_by')
+ ->leftJoin('booking.accommodation', 'accommodation')
+ ->leftJoin('booking.managedBy', 'managed_by')
+ ->addSelect('accommodation')
+ ;
+
+ $this->applySearchTerm($qb, $filter->searchTerm(), [
+ 'booking.groupName',
+ 'booking.firstName',
+ 'booking.lastName',
+ 'booking.email',
+ 'booking.city',
+ 'booking.zip',
+ 'booking.uuid',
+ 'accommodation.name',
+ ]);
+
+ // The filtered period is matched against the stay, so "ab heute" keeps everything that
+ // is not over yet rather than only what starts from today onwards.
+ $this->applyDateOverlap($qb, 'booking.dateFrom', 'booking.dateTo', $filter->dateFrom, $filter->dateTo);
+
+ if ([] !== $filter->status) {
+ $qb->andWhere('booking.status IN (:status)')->setParameter('status', $filter->status);
+ }
+
+ if ([] !== $filter->type) {
+ $qb->andWhere('booking.type IN (:type)')->setParameter('type', $filter->type);
+ }
+
+ if ($filter->unassigned) {
+ $qb->andWhere('booking.managedBy IS NULL');
+ } elseif (null !== $filter->managedBy) {
+ $qb->andWhere('booking.managedBy = :managedBy')->setParameter('managedBy', $filter->managedBy);
+ }
+
+ if (null !== $filter->accommodation) {
+ $qb->andWhere('booking.accommodation = :accommodation')->setParameter('accommodation', $filter->accommodation);
+ }
+
+ return $qb;
+ }
}
diff --git a/src/Repository/LogEntryRepository.php b/src/Repository/LogEntryRepository.php
index f15aa8b..65cda1c 100644
--- a/src/Repository/LogEntryRepository.php
+++ b/src/Repository/LogEntryRepository.php
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\LogEntry;
+use App\Form\Model\Filter\LogEntryFilterDto;
+use App\Repository\Filter\AppliesListFiltersTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -13,11 +16,51 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class LogEntryRepository extends ServiceEntityRepository
{
+ use AppliesListFiltersTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, LogEntry::class);
}
+ public function createFilteredQueryBuilder(LogEntryFilterDto $filter): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('log_entry');
+
+ // The user, request id and URI shown in the list are read out of the `extra` JSON and
+ // have no column of their own, so the search stays on what the database can index.
+ $this->applySearchTerm($qb, $filter->searchTerm(), [
+ 'log_entry.message',
+ 'log_entry.errorCode',
+ ]);
+
+ $this->applyDateWithin($qb, 'log_entry.createdAt', $filter->dateFrom, $filter->dateTo);
+
+ if (null !== $filter->channel && '' !== $filter->channel) {
+ $qb->andWhere('log_entry.channel = :channel')->setParameter('channel', $filter->channel);
+ }
+
+ return $qb;
+ }
+
+ /**
+ * The channels actually present, so the filter never offers an empty result.
+ *
+ * @return string[]
+ */
+ public function findChannels(): array
+ {
+ /** @var list $rows */
+ $rows = $this->createQueryBuilder('log_entry')
+ ->select('DISTINCT log_entry.channel AS channel')
+ ->orderBy('log_entry.channel', 'ASC')
+ ->getQuery()
+ ->getScalarResult()
+ ;
+
+ return array_values(array_filter(array_column($rows, 'channel')));
+ }
+
public function deleteOlderThan(\DateTimeImmutable $threshold): int
{
return $this->createQueryBuilder('l')
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 63e1e6f..6deb06c 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\User;
+use App\Form\Model\Filter\UserFilterDto;
+use App\Repository\Filter\AppliesListFiltersTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
/**
@@ -13,11 +16,38 @@ use Doctrine\Persistence\ManagerRegistry;
*/
class UserRepository extends ServiceEntityRepository
{
+ use AppliesListFiltersTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
+ public function createFilteredQueryBuilder(UserFilterDto $filter): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('user');
+
+ $this->applySearchTerm($qb, $filter->searchTerm(), [
+ 'user.email',
+ 'user.addressId',
+ 'user.personId',
+ ]);
+
+ $this->applyDateWithin($qb, 'user.lastLoginAt', $filter->dateFrom, $filter->dateTo);
+
+ if (null !== $filter->role && '' !== $filter->role) {
+ // Same reasoning as findGroupsStaff(): the roles are a JSON array column and no
+ // JSON_CONTAINS is registered, so the needle carries its quotes to stay anchored
+ // to a whole array entry.
+ $qb
+ ->andWhere('user.roles LIKE :role')
+ ->setParameter('role', '%"'.$filter->role.'"%')
+ ;
+ }
+
+ return $qb;
+ }
+
/**
* Users who can look after a group booking. The roles are a JSON array column and no
* JSON_CONTAINS is registered, so the needles carry their quotes to stay anchored to
diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php
index 4b31a37..3104af8 100644
--- a/src/Twig/AppExtension.php
+++ b/src/Twig/AppExtension.php
@@ -44,6 +44,7 @@ class AppExtension extends AbstractExtension
new TwigFunction('gtm_id', [AppRuntime::class, 'getGtmId']),
new TwigFunction('cmp_url', [AppRuntime::class, 'getCmpUrl']),
new TwigFunction('return_url', [AppRuntime::class, 'getEncodedReturnUrl']),
+ new TwigFunction('filter_query_without', [AppRuntime::class, 'getFilterQueryWithout']),
];
}
}
diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php
index 9c03b32..249f493 100644
--- a/src/Twig/AppRuntime.php
+++ b/src/Twig/AppRuntime.php
@@ -7,6 +7,7 @@ namespace App\Twig;
use App\BusProNet\DataProvider\CountryDataProvider;
use App\EventListener\DomainThemeListener;
use App\Form\Model\BookingDto;
+use App\Form\Model\Filter\AbstractListFilterDto;
use App\Form\Service\Condition\TravelStartCutoffReachedCondition;
use App\Form\Service\CreateFieldStateProvider;
use App\Form\Service\EditFieldStateProvider;
@@ -294,6 +295,37 @@ class AppRuntime implements RuntimeExtensionInterface
return rawurlencode($masterRequest->getRequestUri());
}
+ /**
+ * The current query string with the given keys removed — the target of a filter chip's
+ * dismiss link.
+ *
+ * The pagination is reset because the shorter result set makes the current page meaningless,
+ * and the filter marker is kept because dismissing a chip is still an explicit filter action:
+ * dropping it would snap the list back to its defaults instead of widening it.
+ *
+ * @param string[] $keys
+ *
+ * @return array
+ */
+ public function getFilterQueryWithout(array $keys): array
+ {
+ $request = $this->requestStack->getCurrentRequest();
+
+ if (null === $request) {
+ return [];
+ }
+
+ $query = $request->query->all();
+
+ foreach ([...$keys, 'page'] as $key) {
+ unset($query[$key]);
+ }
+
+ $query[AbstractListFilterDto::MARKER] = '1';
+
+ return $query;
+ }
+
private function getDomainConfig(): DomainConfig
{
$request = $this->requestStack->getCurrentRequest();
diff --git a/templates/_partials/_list_filter_bar.html.twig b/templates/_partials/_list_filter_bar.html.twig
new file mode 100644
index 0000000..41aa674
--- /dev/null
+++ b/templates/_partials/_list_filter_bar.html.twig
@@ -0,0 +1,70 @@
+{#
+ Generic filter bar for admin list views.
+
+ Expects:
+ - filter: the AbstractListFilterDto the list was actually built from
+ - form: FormView of the entity's filter form (only `q` is rendered here)
+ - route: route name of the list itself
+ - modal_route: route name of the filter modal
+#}
+{% import '_partials/_query_passthrough.html.twig' as passthrough %}
+
+{% set chips = filter.activeFilters %}
+
+
+
+ {# Everything except the search term itself rides along, so searching narrows the current
+ filter instead of replacing it. The page is dropped on purpose: a new search starts at 1. #}
+
+
+
+
+ {% if filter.isActive %}
+
+ zurücksetzen
+
+ {% endif %}
+
diff --git a/templates/_partials/_query_passthrough.html.twig b/templates/_partials/_query_passthrough.html.twig
new file mode 100644
index 0000000..918ff19
--- /dev/null
+++ b/templates/_partials/_query_passthrough.html.twig
@@ -0,0 +1,17 @@
+{#
+ Renders query parameters as hidden inputs so they survive a filter submit.
+
+ A filter form only submits its own fields, so anything else that describes the current list —
+ the sorting, the page size, the return URL — has to be carried along explicitly or it is lost
+ the moment somebody searches.
+#}
+{% macro render(values, prefix) %}
+ {%- for key, value in values -%}
+ {%- set name = prefix ? prefix ~ '[' ~ key ~ ']' : key -%}
+ {%- if value is iterable -%}
+ {{ _self.render(value, name) }}
+ {%- else -%}
+
+ {%- endif -%}
+ {%- endfor -%}
+{% endmacro %}
diff --git a/templates/admin/_modal_filter.html.twig b/templates/admin/_modal_filter.html.twig
new file mode 100644
index 0000000..7ec1d1d
--- /dev/null
+++ b/templates/admin/_modal_filter.html.twig
@@ -0,0 +1,45 @@
+{#
+ Shared filter modal for admin list views.
+
+ Expects:
+ - form: FormView of the entity's filter form (a GET form pointing at the list route)
+ - route: route name of the list, used by the reset link
+
+ Entity templates extend this and override `fields` when their filters need more layout than
+ a plain form_rest gives them.
+#}
+{% extends 'htmx_modal_admin.html.twig' %}
+{% form_theme form 'forms_admin.html.twig' %}
+{% import '_partials/_query_passthrough.html.twig' as passthrough %}
+
+{% block title %}Filter{% endblock %}
+
+{% block content %}
+ {{ form_start(form) }}
+ {# Sorting, page size and the return URL are not filters, but they describe the same list. #}
+ {{ passthrough.render(app.request.query.all|filter((value, key) => key in ['sort', 'direction', 'limit', 'r'])) }}
+ {# The filter marker is a field of the form, so render it here rather than leaving it to a
+ form_rest() call an entity template might not make. #}
+ {{ form_widget(form.f) }}
+
+
+ {# Both are group-admin only, but they appear independently: triage by "no assignment" works
+ even before anybody holds a groups role and there is a manager to pick. #}
+ {% if form.managedBy is defined %}
+
+ {{ form_row(form.managedBy) }}
+
+ {% endif %}
+ {% if form.unassigned is defined %}
+