feat: admin list filtering and search

This commit is contained in:
Björn Fromme committed 2026-08-06 10:57:36 +02:00
1 parent aa8fa5c1b2
commit dd658bf9d6
41 files changed
+1977 -44

No files matched your search

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Form\Model\Filter\AbstractListFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Base for every admin list filter form.
*
* The form submits as GET straight to the list route, so the filter it produces *is* the URL.
* Its block prefix is deliberately empty: that gives readable parameters (`?q=meier&status[]=open`
* instead of `?filter[q]=…`) and lets handleRequest() bind the whole query string in one go —
* which in turn is why extra fields have to be tolerated, as page, limit, sort, direction, the
* return URL and the filter marker all travel in that same query string.
*
* @template T of AbstractListFilterDto
*
* @extends AbstractType<T>
*/
abstract class AbstractListFilterType extends AbstractType
{
/**
* @param FormBuilderInterface<T|null> $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<string, mixed> $options
*
* @return array<string, array{class-string, array<string, mixed>}>
*/
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';
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Entity\User;
use App\Repository\Groups\AccommodationBookingRepository;
use App\Repository\Groups\AccommodationRepository;
use App\Repository\UserRepository;
/**
* The choice lists for the accommodation booking filter form.
*
* The list view and the filter modal have to configure the form identically: the modal writes
* the query string, the list hydrates the very same form from it, and a field missing on either
* side would silently drop that filter.
*/
final readonly class AccommodationBookingFilterOptionsProvider
{
public function __construct(
private UserRepository $userRepository,
private AccommodationBookingRepository $bookingRepository,
private AccommodationRepository $accommodationRepository,
) {
}
/**
* @param bool $seesAllBookings whether the current user may look past their own bookings
*
* @return array<string, mixed>
*/
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);
}
}
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Entity\Groups\Accommodation;
use App\Entity\User;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Enum\Groups\AccommodationBookingType;
use App\Form\Model\Filter\AccommodationBookingFilterDto;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractListFilterType<AccommodationBookingFilterDto>
*/
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';
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Entity\User;
use App\Form\Model\Filter\BookingEditDraftFilterDto;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractListFilterType<BookingEditDraftFilterDto>
*/
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';
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Form\Model\Filter\LogEntryFilterDto;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractListFilterType<LogEntryFilterDto>
*/
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';
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin\Filter;
use App\Form\Model\Filter\UserFilterDto;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractListFilterType<UserFilterDto>
*/
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';
}
}
@@ -54,7 +54,6 @@ class AccommodationBookingCreateType extends AbstractType
'choices' => [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Accepted,
],
'choice_label' => fn (AccommodationBookingStatus $status) => $status->label(),
])