feat: admin list filtering and search
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user