feat: admin list filtering and search
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Renders the filter modal, pre-filled with whatever the list is currently showing.
|
||||
*
|
||||
* There is no submit action to go with it: the form is a GET form pointing at the list itself,
|
||||
* so applying a filter is a plain navigation.
|
||||
*/
|
||||
#[IsGranted('ROLE_GROUPS_MANAGER')]
|
||||
class FilterController extends AbstractController
|
||||
{
|
||||
use ListFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly AccommodationBookingFilterOptionsProvider $filterOptions,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/accommodation-booking/filter', name: 'app_admin_accommodationbooking_filter', methods: ['GET'])]
|
||||
public function filter(Request $request): Response
|
||||
{
|
||||
// ROLE_ADMIN inherits ROLE_GROUPS_ADMIN, so this single check covers both.
|
||||
$seesAllBookings = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin\BookingEditDraft;
|
||||
|
||||
use App\Controller\Traits\ListFilterTrait;
|
||||
use App\Form\Admin\Filter\BookingEditDraftFilterType;
|
||||
use App\Form\Model\Filter\BookingEditDraftFilterDto;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_GROUPS_ADMIN')]
|
||||
class FilterController extends AbstractController
|
||||
{
|
||||
use ListFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingEditDraftRepository $bookingEditDraftRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/booking-edit-draft/filter', name: 'app_admin_bookingeditdraft_filter', methods: ['GET'])]
|
||||
public function filter(Request $request): Response
|
||||
{
|
||||
$filterView = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class FilterController extends AbstractController
|
||||
{
|
||||
use ListFilterTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly LogEntryRepository $logEntryRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/admin/log/filter', name: 'app_admin_log_filter', methods: ['GET'])]
|
||||
public function filter(Request $request): Response
|
||||
{
|
||||
$filterView = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
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 Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class FilterController extends AbstractController
|
||||
{
|
||||
use ListFilterTrait;
|
||||
|
||||
#[Route('/admin/user/filter', name: 'app_admin_user_filter', methods: ['GET'])]
|
||||
public function filter(Request $request): Response
|
||||
{
|
||||
$filterView = $this->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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Traits;
|
||||
|
||||
use App\Form\Model\Filter\AbstractListFilterDto;
|
||||
use Symfony\Component\Form\FormView;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Shared wiring between a list view and its filter modal — both need the same form, bound to
|
||||
* the same query string.
|
||||
*/
|
||||
trait ListFilterTrait
|
||||
{
|
||||
/**
|
||||
* Builds the filter form and binds it to the query string, but only when the request is
|
||||
* marked as explicitly filtered. On a bare URL the passed-in defaults survive untouched,
|
||||
* which is what makes "reset" simply mean "link to the route without a query string".
|
||||
*
|
||||
* The form writes straight into $defaults, so callers read the resolved filter from the
|
||||
* object they passed in — keeping its concrete type, same as the entity edit forms — and
|
||||
* get back only what they still need for rendering.
|
||||
*
|
||||
* @param class-string $formType
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
protected function createListFilterView(
|
||||
Request $request,
|
||||
string $formType,
|
||||
AbstractListFilterDto $defaults,
|
||||
string $listRoute,
|
||||
array $options = [],
|
||||
): FormView {
|
||||
$listUrl = $this->generateUrl($listRoute);
|
||||
|
||||
// The modal lives inside <body>, 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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model\Filter;
|
||||
|
||||
use App\Model\ListFilterChip;
|
||||
|
||||
/**
|
||||
* Shared state of every admin list filter: a generic search term and a date range.
|
||||
*
|
||||
* The whole filter lives in the query string, so a filtered list is bookmarkable, survives the
|
||||
* back button and is carried through pagination and sorting by Knp's `query|merge`.
|
||||
*/
|
||||
abstract class AbstractListFilterDto
|
||||
{
|
||||
/**
|
||||
* Query key marking a request as explicitly filtered by the user.
|
||||
*
|
||||
* Its presence is what separates "show me the defaults" from "show me exactly what the URL
|
||||
* says". Without it an empty filter would be indistinguishable from a fresh visit, and there
|
||||
* would be no way to say "no status filter at all" once a default status exists.
|
||||
*/
|
||||
public const MARKER = 'f';
|
||||
|
||||
public ?string $q = null;
|
||||
|
||||
public ?\DateTimeImmutable $dateFrom = null;
|
||||
|
||||
public ?\DateTimeImmutable $dateTo = null;
|
||||
|
||||
/**
|
||||
* The search term, normalised — null when the user submitted nothing but whitespace.
|
||||
*/
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
$term = trim((string) $this->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model\Filter;
|
||||
|
||||
use App\Entity\Groups\Accommodation;
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Enum\Groups\AccommodationBookingType;
|
||||
use App\Model\ListFilterChip;
|
||||
|
||||
class AccommodationBookingFilterDto extends AbstractListFilterDto
|
||||
{
|
||||
/** @var AccommodationBookingStatus[] */
|
||||
public array $status = [];
|
||||
|
||||
/** @var AccommodationBookingType[] */
|
||||
public array $type = [];
|
||||
|
||||
public ?User $managedBy = null;
|
||||
|
||||
/**
|
||||
* Bookings nobody is responsible for yet — the triage case. Takes precedence over
|
||||
* $managedBy, which cannot be true at the same time without contradicting itself.
|
||||
*/
|
||||
public bool $unassigned = false;
|
||||
|
||||
public ?Accommodation $accommodation = null;
|
||||
|
||||
/**
|
||||
* Whether the manager scope is imposed rather than chosen. A group manager without admin
|
||||
* rights only ever sees their own bookings, so the chip has to be shown — otherwise the
|
||||
* short list looks like a bug — but must not offer a way out that would not work.
|
||||
*/
|
||||
public bool $managedByLocked = false;
|
||||
|
||||
/**
|
||||
* The list as it presents itself to someone who has not filtered yet.
|
||||
*
|
||||
* The still-live statuses — drafts and open bookings, i.e. everything that may still need
|
||||
* work — nothing whose stay is already over, and, for staff who are not group admins, only
|
||||
* the bookings they are responsible for.
|
||||
*/
|
||||
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
|
||||
{
|
||||
$filter = new self();
|
||||
$filter->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model\Filter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Model\ListFilterChip;
|
||||
|
||||
class BookingEditDraftFilterDto extends AbstractListFilterDto
|
||||
{
|
||||
public ?User $user = null;
|
||||
|
||||
public function activeFilters(): array
|
||||
{
|
||||
$chips = parent::activeFilters();
|
||||
|
||||
if (null !== $this->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model\Filter;
|
||||
|
||||
use App\Model\ListFilterChip;
|
||||
|
||||
class LogEntryFilterDto extends AbstractListFilterDto
|
||||
{
|
||||
public ?string $channel = null;
|
||||
|
||||
public function activeFilters(): array
|
||||
{
|
||||
$chips = parent::activeFilters();
|
||||
|
||||
if (null !== $this->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model\Filter;
|
||||
|
||||
use App\Model\ListFilterChip;
|
||||
|
||||
class UserFilterDto extends AbstractListFilterDto
|
||||
{
|
||||
public ?string $role = null;
|
||||
|
||||
public function activeFilters(): array
|
||||
{
|
||||
$chips = parent::activeFilters();
|
||||
|
||||
if (null !== $this->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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model;
|
||||
|
||||
/**
|
||||
* A single active filter, rendered as a chip above a list view.
|
||||
*
|
||||
* Chips exist so that a filtered list explains itself — especially when the filter was not
|
||||
* chosen by the user but applied as a role-dependent default.
|
||||
*/
|
||||
final readonly class ListFilterChip
|
||||
{
|
||||
/**
|
||||
* @param string[] $removeKeys Query keys dropped when the chip is dismissed. An empty list
|
||||
* marks a chip the user is not allowed to remove, e.g. the
|
||||
* manager scope a non-admin is pinned to.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public string $value,
|
||||
public array $removeKeys = [],
|
||||
) {
|
||||
}
|
||||
|
||||
public function isRemovable(): bool
|
||||
{
|
||||
return [] !== $this->removeKeys;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Filter;
|
||||
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* The two filter mechanics every admin list shares: a generic search term and a date range.
|
||||
*
|
||||
* Kept as a trait next to the repositories that use it, mirroring FindsByAccommodationAndDateRangeTrait.
|
||||
*/
|
||||
trait AppliesListFiltersTrait
|
||||
{
|
||||
/**
|
||||
* Narrows the query by a whitespace-separated search term.
|
||||
*
|
||||
* Every token must match at least one of the given fields, so "meier bonn" finds the Meier
|
||||
* group in Bonn rather than everything called Meier plus everything in Bonn.
|
||||
*
|
||||
* @param string[] $fields DQL field references, e.g. ['booking.groupName', 'accommodation.name']
|
||||
*/
|
||||
protected function applySearchTerm(QueryBuilder $qb, ?string $term, array $fields): void
|
||||
{
|
||||
if (null === $term || '' === trim($term) || [] === $fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokens = preg_split('/\s+/', trim($term), -1, \PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
|
||||
foreach ($tokens as $index => $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'))
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<array{channel: string|null}> $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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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();
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{# 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. #}
|
||||
<form hx-get="{{ path(route) }}"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
action="{{ path(route) }}"
|
||||
method="get"
|
||||
class="flex items-center gap-2">
|
||||
{{ passthrough.render(app.request.query.all|filter((value, key) => key not in ['q', 'page', 'f'])) }}
|
||||
<input type="hidden" name="f" value="1">
|
||||
<div class="relative">
|
||||
<input type="search"
|
||||
name="q"
|
||||
value="{{ filter.q }}"
|
||||
placeholder="{{ form.q.vars.attr.placeholder|default('Suchen…') }}"
|
||||
class="block w-64 rounded-md border-0 py-1.5 pl-9 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-primary text-sm">
|
||||
<div class="absolute inset-y-0 left-0 flex items-center pl-2.5 pointer-events-none">
|
||||
{{ icon('search', 'w-4 h-4 text-gray-500') }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<button type="button"
|
||||
hx-get="{{ path(modal_route, app.request.query.all) }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="button button--secondary button--small inline-flex items-center gap-1.5">
|
||||
{{ icon('filter', 'w-4 h-4') }}
|
||||
Filter{% if filter.activeCount > 0 %} ({{ filter.activeCount }}){% endif %}
|
||||
</button>
|
||||
|
||||
{% if filter.isActive %}
|
||||
<a href="{{ path(route) }}" class="text-sm text-gray-600 underline hover:text-gray-900">
|
||||
zurücksetzen
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if chips|length > 0 %}
|
||||
<div class="flex flex-wrap items-center gap-2 pt-3">
|
||||
{% for chip in chips %}
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full bg-gray-100 border border-gray-300 pl-3 {{ chip.isRemovable ? 'pr-1' : 'pr-3' }} py-1 text-xs text-gray-700">
|
||||
<span><span class="font-bold">{{ chip.label }}:</span> {{ chip.value }}</span>
|
||||
{% if chip.isRemovable %}
|
||||
<a href="{{ path(route, filter_query_without(chip.removeKeys)) }}"
|
||||
class="inline-flex items-center justify-center w-4 h-4 rounded-full hover:bg-gray-300"
|
||||
aria-label="Filter „{{ chip.label }}“ entfernen">
|
||||
{{ icon('close', 'w-3 h-3') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -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 -%}
|
||||
<input type="hidden" name="{{ name }}" value="{{ value }}">
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{% endmacro %}
|
||||
@@ -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) }}
|
||||
|
||||
<div class="grid lg:grid-cols-2 gap-y-4 lg:gap-x-8">
|
||||
{% block fields %}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.q) }}
|
||||
</div>
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-8">
|
||||
<a href="{{ path(route) }}" class="text-sm text-gray-600 underline hover:text-gray-900">
|
||||
auf Standard zurücksetzen
|
||||
</a>
|
||||
<button type="submit" class="button button--primary button--small">
|
||||
anwenden
|
||||
</button>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Buchungen & Anfragen</twig:page:heading>
|
||||
{{ include('_partials/_list_filter_bar.html.twig', {
|
||||
'filter': filter,
|
||||
'form': filter_form,
|
||||
'route': 'app_admin_accommodationbooking',
|
||||
'modal_route': 'app_admin_accommodationbooking_filter',
|
||||
}) }}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
@@ -88,8 +94,8 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
Keine Daten...
|
||||
<td colspan="9">
|
||||
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends 'admin/_modal_filter.html.twig' %}
|
||||
|
||||
{% block fields %}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.q) }}
|
||||
</div>
|
||||
{{ form_row(form.dateFrom) }}
|
||||
{{ form_row(form.dateTo) }}
|
||||
{{ form_row(form.status) }}
|
||||
{{ form_row(form.type) }}
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.accommodation) }}
|
||||
</div>
|
||||
{# 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 %}
|
||||
<div>
|
||||
{{ form_row(form.managedBy) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.unassigned is defined %}
|
||||
<div class="flex items-end pb-1">
|
||||
{{ form_row(form.unassigned) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ form_rest(form) }}
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Buchungsentwürfe</twig:page:heading>
|
||||
{{ include('_partials/_list_filter_bar.html.twig', {
|
||||
'filter': filter,
|
||||
'form': filter_form,
|
||||
'route': 'app_admin_bookingeditdraft',
|
||||
'modal_route': 'app_admin_bookingeditdraft_filter',
|
||||
}) }}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
@@ -58,7 +64,7 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
Keine Daten...
|
||||
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Logs</twig:page:heading>
|
||||
{{ include('_partials/_list_filter_bar.html.twig', {
|
||||
'filter': filter,
|
||||
'form': filter_form,
|
||||
'route': 'app_admin_log',
|
||||
'modal_route': 'app_admin_log_filter',
|
||||
}) }}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{{ knp_pagination_sortable(pagination, 'Datum', 'log.timestamp') }}
|
||||
{{ knp_pagination_sortable(pagination, 'Datum', 'log_entry.createdAt') }}
|
||||
</th>
|
||||
<th>
|
||||
Fehlercode
|
||||
@@ -62,7 +68,7 @@
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
Keine Daten...
|
||||
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
{% block content %}
|
||||
<twig:page:heading>Benutzeraccounts</twig:page:heading>
|
||||
{{ include('_partials/_list_filter_bar.html.twig', {
|
||||
'filter': filter,
|
||||
'form': filter_form,
|
||||
'route': 'app_admin_user',
|
||||
'modal_route': 'app_admin_user_filter',
|
||||
}) }}
|
||||
<div class="data-table-wrapper">
|
||||
<div class="data-table-wrapper__inner text-sm">
|
||||
<table class="data-table">
|
||||
@@ -39,8 +45,8 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
Keine Daten...
|
||||
<td colspan="4">
|
||||
{{ filter.isActive ? 'Keine Treffer für diesen Filter.' : 'Keine Daten...' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Admin\Filter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\Admin\Filter\AccommodationBookingFilterOptionsProvider;
|
||||
use App\Repository\Groups\AccommodationBookingRepository;
|
||||
use App\Repository\Groups\AccommodationRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationBookingFilterOptionsProviderTest extends TestCase
|
||||
{
|
||||
public function testSomebodyAssignedWithoutTheRoleStaysFilterable(): void
|
||||
{
|
||||
$staff = $this->user(1, '[email protected]');
|
||||
$formerStaff = $this->user(2, '[email protected]');
|
||||
|
||||
$options = $this->provider([$staff], [$formerStaff])->formOptions(true);
|
||||
|
||||
self::assertSame(
|
||||
['[email protected]', '[email protected]'],
|
||||
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, '[email protected]');
|
||||
|
||||
$options = $this->provider([$manager], [$manager])->formOptions(true);
|
||||
|
||||
self::assertCount(1, $options['managers']);
|
||||
}
|
||||
|
||||
public function testManagersAreSortedByEmail(): void
|
||||
{
|
||||
$options = $this->provider(
|
||||
[$this->user(1, '[email protected]'), $this->user(2, '[email protected]')],
|
||||
[$this->user(3, '[email protected]')],
|
||||
)->formOptions(true);
|
||||
|
||||
self::assertSame(
|
||||
['[email protected]', '[email protected]', '[email protected]'],
|
||||
array_map(static fn (User $u) => $u->getEmail(), $options['managers']),
|
||||
);
|
||||
}
|
||||
|
||||
public function testStaffWhoCannotSeeOtherBookingsGetNoManagerFilterAtAll(): void
|
||||
{
|
||||
$options = $this->provider([$this->user(1, '[email protected]')], [])->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Model\Filter;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Enum\Groups\AccommodationBookingStatus;
|
||||
use App\Enum\Groups\AccommodationBookingType;
|
||||
use App\Form\Model\Filter\AccommodationBookingFilterDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AccommodationBookingFilterDtoTest extends TestCase
|
||||
{
|
||||
public function testGroupAdminsStartOnStillLiveBookingsThatAreNotOverYet(): void
|
||||
{
|
||||
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
|
||||
|
||||
self::assertSame(
|
||||
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open],
|
||||
$filter->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('[email protected]');
|
||||
|
||||
$filter = AccommodationBookingFilterDto::defaults(false, $user);
|
||||
|
||||
self::assertSame($user, $filter->managedBy);
|
||||
self::assertTrue($filter->managedByLocked);
|
||||
}
|
||||
|
||||
public function testThePinnedManagerChipCannotBeDismissed(): void
|
||||
{
|
||||
$filter = AccommodationBookingFilterDto::defaults(false, new User('[email protected]'));
|
||||
|
||||
$chip = $this->chipFor($filter, 'Betreuer:in');
|
||||
self::assertNotNull($chip);
|
||||
self::assertFalse($chip->isRemovable());
|
||||
}
|
||||
|
||||
public function testAChosenManagerChipCanBeDismissed(): void
|
||||
{
|
||||
$filter = new AccommodationBookingFilterDto();
|
||||
$filter->managedBy = new User('[email protected]');
|
||||
|
||||
$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('[email protected]');
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository\Filter;
|
||||
|
||||
use App\Repository\Filter\AppliesListFiltersTrait;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AppliesListFiltersTraitTest extends TestCase
|
||||
{
|
||||
public function testEachTokenHasToMatchAtLeastOneField(): void
|
||||
{
|
||||
$qb = $this->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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user