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. #} +
+ {{ passthrough.render(app.request.query.all|filter((value, key) => key not in ['q', 'page', 'f'])) }} + +
+ +
+ {{ icon('search', 'w-4 h-4 text-gray-500') }} +
+
+
+ + + + {% if filter.isActive %} + + zurücksetzen + + {% endif %} +
+ + {% if chips|length > 0 %} +
+ {% for chip in chips %} + + {{ chip.label }}: {{ chip.value }} + {% if chip.isRemovable %} + + {{ icon('close', 'w-3 h-3') }} + + {% endif %} + + {% endfor %} +
+ {% 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) }} + +
+ {% block fields %} +
+ {{ form_row(form.q) }} +
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_rest(form) }} + {% endblock %} +
+ + + {{ form_end(form) }} +{% endblock %} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 654c78b..37c1a5e 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -2,6 +2,12 @@ {% block content %} Buchungen & Anfragen + {{ include('_partials/_list_filter_bar.html.twig', { + 'filter': filter, + 'form': filter_form, + 'route': 'app_admin_accommodationbooking', + 'modal_route': 'app_admin_accommodationbooking_filter', + }) }}
@@ -88,8 +94,8 @@ {% else %} - {% endfor %} diff --git a/templates/admin/accommodation_booking/modal_filter.html.twig b/templates/admin/accommodation_booking/modal_filter.html.twig new file mode 100644 index 0000000..afddd23 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_filter.html.twig @@ -0,0 +1,27 @@ +{% extends 'admin/_modal_filter.html.twig' %} + +{% block fields %} +
+ {{ form_row(form.q) }} +
+ {{ form_row(form.dateFrom) }} + {{ form_row(form.dateTo) }} + {{ form_row(form.status) }} + {{ form_row(form.type) }} +
+ {{ form_row(form.accommodation) }} +
+ {# 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 %} +
+ {{ form_row(form.unassigned) }} +
+ {% endif %} + {{ form_rest(form) }} +{% endblock %} diff --git a/templates/admin/booking_edit_draft/index.html.twig b/templates/admin/booking_edit_draft/index.html.twig index 6ff0270..0b1bab3 100644 --- a/templates/admin/booking_edit_draft/index.html.twig +++ b/templates/admin/booking_edit_draft/index.html.twig @@ -2,6 +2,12 @@ {% block content %} Buchungsentwürfe + {{ include('_partials/_list_filter_bar.html.twig', { + 'filter': filter, + 'form': filter_form, + 'route': 'app_admin_bookingeditdraft', + 'modal_route': 'app_admin_bookingeditdraft_filter', + }) }}
- Keine Daten... + + {{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
@@ -58,7 +64,7 @@ {% else %} {% endfor %} diff --git a/templates/admin/log/index.html.twig b/templates/admin/log/index.html.twig index d81698a..bec7418 100644 --- a/templates/admin/log/index.html.twig +++ b/templates/admin/log/index.html.twig @@ -2,13 +2,19 @@ {% block content %} Logs + {{ include('_partials/_list_filter_bar.html.twig', { + 'filter': filter, + 'form': filter_form, + 'route': 'app_admin_log', + 'modal_route': 'app_admin_log_filter', + }) }}
- Keine Daten... + {{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
{% endfor %} diff --git a/templates/admin/user/index.html.twig b/templates/admin/user/index.html.twig index c4178cf..d72b041 100644 --- a/templates/admin/user/index.html.twig +++ b/templates/admin/user/index.html.twig @@ -2,6 +2,12 @@ {% block content %} Benutzeraccounts + {{ include('_partials/_list_filter_bar.html.twig', { + 'filter': filter, + 'form': filter_form, + 'route': 'app_admin_user', + 'modal_route': 'app_admin_user_filter', + }) }}
- {{ knp_pagination_sortable(pagination, 'Datum', 'log.timestamp') }} + {{ knp_pagination_sortable(pagination, 'Datum', 'log_entry.createdAt') }} Fehlercode @@ -62,7 +68,7 @@ {% else %}
- Keine Daten... + {{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
@@ -39,8 +45,8 @@ {% else %} - {% endfor %} diff --git a/tests/Form/Admin/Filter/AccommodationBookingFilterOptionsProviderTest.php b/tests/Form/Admin/Filter/AccommodationBookingFilterOptionsProviderTest.php new file mode 100644 index 0000000..cdc2253 --- /dev/null +++ b/tests/Form/Admin/Filter/AccommodationBookingFilterOptionsProviderTest.php @@ -0,0 +1,97 @@ +user(1, 'staff@example.test'); + $formerStaff = $this->user(2, 'former@example.test'); + + $options = $this->provider([$staff], [$formerStaff])->formOptions(true); + + self::assertSame( + ['former@example.test', 'staff@example.test'], + array_map(static fn (User $u) => $u->getEmail(), $options['managers']), + 'a booking assigned to someone who lost the groups role has to remain findable', + ); + } + + public function testSomebodyBothAssignedAndOnStaffIsOfferedOnce(): void + { + $manager = $this->user(1, 'manager@example.test'); + + $options = $this->provider([$manager], [$manager])->formOptions(true); + + self::assertCount(1, $options['managers']); + } + + public function testManagersAreSortedByEmail(): void + { + $options = $this->provider( + [$this->user(1, 'zoe@example.test'), $this->user(2, 'anna@example.test')], + [$this->user(3, 'Mia@example.test')], + )->formOptions(true); + + self::assertSame( + ['anna@example.test', 'Mia@example.test', 'zoe@example.test'], + array_map(static fn (User $u) => $u->getEmail(), $options['managers']), + ); + } + + public function testStaffWhoCannotSeeOtherBookingsGetNoManagerFilterAtAll(): void + { + $options = $this->provider([$this->user(1, 'staff@example.test')], [])->formOptions(false); + + self::assertFalse($options['can_filter_by_manager']); + self::assertSame([], $options['managers']); + } + + public function testTheManagerFilterIsAPermissionNotAConsequenceOfEmptyData(): void + { + // Nobody holds a groups role and nothing is assigned yet — a group admin still gets to + // filter, which is what keeps the "no assignment" triage available on day one. + $options = $this->provider([], [])->formOptions(true); + + self::assertTrue($options['can_filter_by_manager']); + self::assertSame([], $options['managers']); + } + + /** + * @param User[] $groupsStaff + * @param User[] $assigned + */ + private function provider(array $groupsStaff, array $assigned): AccommodationBookingFilterOptionsProvider + { + $users = $this->createMock(UserRepository::class); + $users->method('findGroupsStaff')->willReturn($groupsStaff); + + $bookings = $this->createMock(AccommodationBookingRepository::class); + $bookings->method('findAssignedManagers')->willReturn($assigned); + + $accommodations = $this->createMock(AccommodationRepository::class); + $accommodations->method('findBy')->willReturn([]); + + return new AccommodationBookingFilterOptionsProvider($users, $bookings, $accommodations); + } + + private function user(int $id, string $email): User + { + $user = new User($email); + + $idProperty = new \ReflectionProperty(User::class, 'id'); + $idProperty->setValue($user, $id); + + return $user; + } +} diff --git a/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php b/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php new file mode 100644 index 0000000..0270db3 --- /dev/null +++ b/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php @@ -0,0 +1,112 @@ +status, + 'accepted and discarded bookings need no further work, so they stay out of the way', + ); + self::assertSame((new \DateTimeImmutable('today'))->format('Y-m-d'), $filter->dateFrom?->format('Y-m-d')); + self::assertNull($filter->managedBy, 'a group admin sees everybody’s bookings'); + self::assertFalse($filter->managedByLocked); + } + + public function testEverybodyElseStartsPinnedToTheirOwnBookings(): void + { + $user = new User('manager@example.test'); + + $filter = AccommodationBookingFilterDto::defaults(false, $user); + + self::assertSame($user, $filter->managedBy); + self::assertTrue($filter->managedByLocked); + } + + public function testThePinnedManagerChipCannotBeDismissed(): void + { + $filter = AccommodationBookingFilterDto::defaults(false, new User('manager@example.test')); + + $chip = $this->chipFor($filter, 'Betreuer:in'); + self::assertNotNull($chip); + self::assertFalse($chip->isRemovable()); + } + + public function testAChosenManagerChipCanBeDismissed(): void + { + $filter = new AccommodationBookingFilterDto(); + $filter->managedBy = new User('manager@example.test'); + + $chip = $this->chipFor($filter, 'Betreuer:in'); + self::assertNotNull($chip); + self::assertTrue($chip->isRemovable()); + self::assertSame(['managedBy'], $chip->removeKeys); + } + + public function testTheUnassignedFilterReplacesTheManagerChip(): void + { + $filter = new AccommodationBookingFilterDto(); + $filter->managedBy = new User('manager@example.test'); + $filter->unassigned = true; + + $chip = $this->chipFor($filter, 'Betreuer:in'); + self::assertNotNull($chip); + self::assertSame('keine Zuordnung', $chip->value); + self::assertSame(['unassigned'], $chip->removeKeys); + } + + public function testAWhitespaceOnlySearchIsNotAFilter(): void + { + $filter = new AccommodationBookingFilterDto(); + $filter->q = ' '; + + self::assertNull($filter->searchTerm()); + self::assertFalse($filter->isActive()); + } + + public function testEveryActiveFilterIsAccountedForAsAChip(): void + { + $filter = new AccommodationBookingFilterDto(); + $filter->q = 'meier'; + $filter->dateFrom = new \DateTimeImmutable('2026-08-01'); + $filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted]; + $filter->type = [AccommodationBookingType::Booking]; + + self::assertSame(4, $filter->activeCount()); + self::assertSame('Offen, Bestätigt', $this->chipFor($filter, 'Status')?->value); + self::assertSame('Buchung', $this->chipFor($filter, 'Art')?->value); + self::assertSame('01.08.2026', $this->chipFor($filter, 'Aufenthalt ab')?->value); + } + + public function testAnUntouchedFilterHasNothingToShow(): void + { + $filter = new AccommodationBookingFilterDto(); + + self::assertSame([], $filter->activeFilters()); + self::assertFalse($filter->isActive()); + } + + private function chipFor(AccommodationBookingFilterDto $filter, string $label): ?\App\Model\ListFilterChip + { + foreach ($filter->activeFilters() as $chip) { + if ($label === $chip->label) { + return $chip; + } + } + + return null; + } +} diff --git a/tests/Repository/Filter/AppliesListFiltersTraitTest.php b/tests/Repository/Filter/AppliesListFiltersTraitTest.php new file mode 100644 index 0000000..0bd69b9 --- /dev/null +++ b/tests/Repository/Filter/AppliesListFiltersTraitTest.php @@ -0,0 +1,127 @@ +queryBuilder(); + + $this->subject()->search($qb, 'meier bonn', ['b.groupName', 'b.city']); + + $dql = (string) $qb->getDQL(); + self::assertStringContainsString('b.groupName LIKE :searchTerm0 OR b.city LIKE :searchTerm0', $dql); + self::assertStringContainsString('b.groupName LIKE :searchTerm1 OR b.city LIKE :searchTerm1', $dql); + self::assertSame('%meier%', $qb->getParameter('searchTerm0')?->getValue()); + self::assertSame('%bonn%', $qb->getParameter('searchTerm1')?->getValue()); + } + + /** + * @dataProvider emptyTerms + */ + public function testAnEmptyTermNarrowsNothing(?string $term): void + { + $qb = $this->queryBuilder(); + + $this->subject()->search($qb, $term, ['b.groupName']); + + self::assertCount(0, $qb->getParameters()); + self::assertStringNotContainsString('LIKE', (string) $qb->getDQL()); + } + + public static function emptyTerms(): iterable + { + yield 'null' => [null]; + yield 'empty' => ['']; + yield 'whitespace' => [" \t "]; + } + + public function testWildcardsTypedByTheUserStayLiteral(): void + { + $qb = $this->queryBuilder(); + + $this->subject()->search($qb, '100%_rabatt', ['b.groupName']); + + self::assertSame('%100\\%\\_rabatt%', $qb->getParameter('searchTerm0')?->getValue()); + } + + public function testTheDateRangeMatchesAnyOverlappingPeriod(): void + { + $qb = $this->queryBuilder(); + $from = new \DateTimeImmutable('2026-08-01'); + $to = new \DateTimeImmutable('2026-08-31'); + + $this->subject()->overlap($qb, 'b.dateFrom', 'b.dateTo', $from, $to); + + $dql = (string) $qb->getDQL(); + // A stay overlaps the filtered period when it ends after it starts and starts before + // it ends — the fields are deliberately crossed over. + self::assertStringContainsString('b.dateTo >= :filterDateFrom', $dql); + self::assertStringContainsString('b.dateFrom <= :filterDateTo', $dql); + self::assertSame($from, $qb->getParameter('filterDateFrom')?->getValue()); + self::assertSame($to, $qb->getParameter('filterDateTo')?->getValue()); + } + + public function testAnOpenEndedRangeOnlyConstrainsTheEndItHas(): void + { + $qb = $this->queryBuilder(); + + $this->subject()->overlap($qb, 'b.dateFrom', 'b.dateTo', new \DateTimeImmutable('2026-08-01'), null); + + self::assertStringNotContainsString('filterDateTo', (string) $qb->getDQL()); + } + + public function testTheLastDayOfARangeIsIncludedForTimestamps(): void + { + $qb = $this->queryBuilder(); + + $this->subject()->within($qb, 'b.createdAt', null, new \DateTimeImmutable('2026-08-31')); + + // Anything logged during 31 August still counts, so the bound moves to the next midnight. + self::assertStringContainsString('b.createdAt < :filterDateTo', (string) $qb->getDQL()); + self::assertSame( + '2026-09-01', + $qb->getParameter('filterDateTo')?->getValue()->format('Y-m-d'), + ); + } + + private function subject(): object + { + return new class { + use AppliesListFiltersTrait; + + /** @param string[] $fields */ + public function search(QueryBuilder $qb, ?string $term, array $fields): void + { + $this->applySearchTerm($qb, $term, $fields); + } + + public function overlap(QueryBuilder $qb, string $fromField, string $toField, ?\DateTimeImmutable $from, ?\DateTimeImmutable $to): void + { + $this->applyDateOverlap($qb, $fromField, $toField, $from, $to); + } + + public function within(QueryBuilder $qb, string $field, ?\DateTimeImmutable $from, ?\DateTimeImmutable $to): void + { + $this->applyDateWithin($qb, $field, $from, $to); + } + }; + } + + private function queryBuilder(): QueryBuilder + { + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getExpressionBuilder')->willReturn(new Expr()); + + return (new QueryBuilder($em))->select('b')->from('Booking', 'b'); + } +}
- Keine Daten... + + {{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}