feat: admin list filtering and search

This commit is contained in:
Björn Fromme
2026-08-06 10:57:36 +02:00
parent aa8fa5c1b2
commit dd658bf9d6
41 changed files with 1977 additions and 44 deletions
@@ -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;
}
}
+43
View File
@@ -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')
+30
View File
@@ -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