feat: Pagination et recherche des sections admin
Les listes admin (utilisateurs, classes, matières, affectations) chargeaient toutes les données d'un coup, ce qui dégradait l'expérience avec un volume croissant. La pagination côté serveur existait dans la config API Platform mais aucun Provider ne l'exploitait. Cette implémentation ajoute la pagination serveur (30 items/page, max 100) avec recherche textuelle sur toutes les sections, des composants frontend réutilisables (Pagination + SearchInput avec debounce), et la synchronisation URL pour le partage de liens filtrés. Les Query valident leurs paramètres (clamp page/limit, trim search) pour éviter les abus. Les affectations utilisent des lookup maps pour résoudre les noms sans N+1 queries. Les pages admin gèrent les race conditions via AbortController.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Dto;
|
||||
|
||||
/**
|
||||
* Résultat paginé générique retourné par les Query Handlers.
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
final readonly class PaginatedResult
|
||||
{
|
||||
public const int DEFAULT_PAGE = 1;
|
||||
public const int DEFAULT_LIMIT = 30;
|
||||
public const int MAX_LIMIT = 100;
|
||||
public const int MAX_SEARCH_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* @param T[] $items Les éléments de la page courante
|
||||
* @param int $total Le nombre total d'éléments (toutes pages confondues)
|
||||
* @param int $page La page courante (1-indexed)
|
||||
* @param int $limit Le nombre d'éléments par page
|
||||
*/
|
||||
public function __construct(
|
||||
public array $items,
|
||||
public int $total,
|
||||
public int $page,
|
||||
public int $limit,
|
||||
) {
|
||||
}
|
||||
|
||||
public function totalPages(): int
|
||||
{
|
||||
if ($this->limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ceil($this->total / $this->limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetAllAssignments;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class AssignmentWithNamesDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $teacherId,
|
||||
public string $teacherFirstName,
|
||||
public string $teacherLastName,
|
||||
public string $classId,
|
||||
public string $className,
|
||||
public string $subjectId,
|
||||
public string $subjectName,
|
||||
public string $academicYearId,
|
||||
public string $status,
|
||||
public DateTimeImmutable $startDate,
|
||||
public ?DateTimeImmutable $endDate,
|
||||
public DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetAllAssignments;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetAllAssignmentsHandler
|
||||
{
|
||||
public function __construct(
|
||||
private TeacherAssignmentRepository $assignmentRepository,
|
||||
private UserRepository $userRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<AssignmentWithNamesDto>
|
||||
*/
|
||||
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
// Build lookup maps for users, classes, and subjects
|
||||
$users = $this->userRepository->findAllByTenant($tenantId);
|
||||
/** @var array<string, array{firstName: string, lastName: string}> $userNames */
|
||||
$userNames = [];
|
||||
foreach ($users as $user) {
|
||||
$userNames[(string) $user->id] = [
|
||||
'firstName' => $user->firstName,
|
||||
'lastName' => $user->lastName,
|
||||
];
|
||||
}
|
||||
|
||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||
/** @var array<string, string> $classNames */
|
||||
$classNames = [];
|
||||
foreach ($classes as $class) {
|
||||
$classNames[(string) $class->id] = (string) $class->name;
|
||||
}
|
||||
|
||||
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
|
||||
/** @var array<string, string> $subjectNames */
|
||||
$subjectNames = [];
|
||||
foreach ($subjects as $subject) {
|
||||
$subjectNames[(string) $subject->id] = (string) $subject->name;
|
||||
}
|
||||
|
||||
$dtos = [];
|
||||
foreach ($assignments as $assignment) {
|
||||
$teacherId = (string) $assignment->teacherId;
|
||||
$classId = (string) $assignment->classId;
|
||||
$subjectId = (string) $assignment->subjectId;
|
||||
|
||||
if (!isset($userNames[$teacherId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'teacherId' => $teacherId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isset($classNames[$classId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'classId' => $classId,
|
||||
]);
|
||||
}
|
||||
|
||||
if (!isset($subjectNames[$subjectId])) {
|
||||
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
|
||||
'assignmentId' => (string) $assignment->id,
|
||||
'subjectId' => $subjectId,
|
||||
]);
|
||||
}
|
||||
|
||||
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
|
||||
|
||||
$dtos[] = new AssignmentWithNamesDto(
|
||||
id: (string) $assignment->id,
|
||||
teacherId: $teacherId,
|
||||
teacherFirstName: $teacher['firstName'],
|
||||
teacherLastName: $teacher['lastName'],
|
||||
classId: $classId,
|
||||
className: $classNames[$classId] ?? '',
|
||||
subjectId: $subjectId,
|
||||
subjectName: $subjectNames[$subjectId] ?? '',
|
||||
academicYearId: (string) $assignment->academicYearId,
|
||||
status: $assignment->status->value,
|
||||
startDate: $assignment->startDate,
|
||||
endDate: $assignment->endDate,
|
||||
createdAt: $assignment->createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$dtos = array_values(array_filter(
|
||||
$dtos,
|
||||
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->className), $searchLower)
|
||||
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
|
||||
));
|
||||
}
|
||||
|
||||
$total = count($dtos);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($dtos, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: $items,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetAllAssignments;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetAllAssignmentsQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer les classes actives d'un tenant.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetClassesHandler
|
||||
{
|
||||
@@ -21,18 +23,37 @@ final readonly class GetClassesHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ClassDto[]
|
||||
* @return PaginatedResult<ClassDto>
|
||||
*/
|
||||
public function __invoke(GetClassesQuery $query): array
|
||||
public function __invoke(GetClassesQuery $query): PaginatedResult
|
||||
{
|
||||
$classes = $this->classRepository->findActiveByTenantAndYear(
|
||||
TenantId::fromString($query->tenantId),
|
||||
AcademicYearId::fromString($query->academicYearId),
|
||||
);
|
||||
|
||||
return array_map(
|
||||
static fn ($class) => ClassDto::fromDomain($class),
|
||||
$classes,
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$classes = array_filter(
|
||||
$classes,
|
||||
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|
||||
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
|
||||
);
|
||||
$classes = array_values($classes);
|
||||
}
|
||||
|
||||
$total = count($classes);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($classes, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
static fn ($class) => ClassDto::fromDomain($class),
|
||||
$items,
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetClasses;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
/**
|
||||
* Query pour récupérer les classes actives d'un tenant pour une année scolaire.
|
||||
*/
|
||||
final readonly class GetClassesQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $academicYearId,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
/**
|
||||
* Handler pour récupérer les matières actives d'un tenant.
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetSubjectsHandler
|
||||
{
|
||||
@@ -24,25 +23,41 @@ final readonly class GetSubjectsHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* @return SubjectDto[]
|
||||
* @return PaginatedResult<SubjectDto>
|
||||
*/
|
||||
public function __invoke(GetSubjectsQuery $query): array
|
||||
public function __invoke(GetSubjectsQuery $query): PaginatedResult
|
||||
{
|
||||
$subjects = $this->subjectRepository->findActiveByTenantAndSchool(
|
||||
TenantId::fromString($query->tenantId),
|
||||
SchoolId::fromString($query->schoolId),
|
||||
);
|
||||
|
||||
// TODO: Récupérer les comptages d'enseignants et de classes
|
||||
// quand les modules Affectations seront implémentés (T7)
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$subjects = array_filter(
|
||||
$subjects,
|
||||
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|
||||
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
|
||||
);
|
||||
$subjects = array_values($subjects);
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn ($subject) => SubjectDto::fromDomain(
|
||||
$subject,
|
||||
teacherCount: 0, // Placeholder - T7
|
||||
classCount: 0, // Placeholder - T7
|
||||
$total = count($subjects);
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($subjects, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
static fn ($subject) => SubjectDto::fromDomain(
|
||||
$subject,
|
||||
teacherCount: 0,
|
||||
classCount: 0,
|
||||
),
|
||||
$items,
|
||||
),
|
||||
$subjects,
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,26 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetSubjects;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
/**
|
||||
* Query pour récupérer les matières actives d'un tenant et d'une école.
|
||||
*/
|
||||
final readonly class GetSubjectsQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public string $schoolId,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetUsers;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\StatutCompte;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_slice;
|
||||
use function count;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
@@ -21,15 +26,14 @@ final readonly class GetUsersHandler
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserDto[]
|
||||
* @return PaginatedResult<UserDto>
|
||||
*/
|
||||
public function __invoke(GetUsersQuery $query): array
|
||||
public function __invoke(GetUsersQuery $query): PaginatedResult
|
||||
{
|
||||
$users = $this->userRepository->findAllByTenant(
|
||||
TenantId::fromString($query->tenantId),
|
||||
);
|
||||
|
||||
// Apply filters
|
||||
if ($query->role !== null) {
|
||||
$filterRole = Role::tryFrom($query->role);
|
||||
if ($filterRole !== null) {
|
||||
@@ -50,9 +54,30 @@ final readonly class GetUsersHandler
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_map(
|
||||
fn ($user) => UserDto::fromDomain($user, $this->clock),
|
||||
$users,
|
||||
));
|
||||
if ($query->search !== null && $query->search !== '') {
|
||||
$searchLower = mb_strtolower($query->search);
|
||||
$users = array_filter(
|
||||
$users,
|
||||
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|
||||
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|
||||
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
|
||||
);
|
||||
}
|
||||
|
||||
$users = array_values($users);
|
||||
$total = count($users);
|
||||
|
||||
$offset = ($query->page - 1) * $query->limit;
|
||||
$items = array_slice($users, $offset, $query->limit);
|
||||
|
||||
return new PaginatedResult(
|
||||
items: array_map(
|
||||
fn ($user) => UserDto::fromDomain($user, $this->clock),
|
||||
$items,
|
||||
),
|
||||
total: $total,
|
||||
page: $query->page,
|
||||
limit: $query->limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Query\GetUsers;
|
||||
|
||||
use App\Administration\Application\Dto\PaginatedResult;
|
||||
|
||||
final readonly class GetUsersQuery
|
||||
{
|
||||
public int $page;
|
||||
public int $limit;
|
||||
public ?string $search;
|
||||
|
||||
public function __construct(
|
||||
public string $tenantId,
|
||||
public ?string $role = null,
|
||||
public ?string $statut = null,
|
||||
int $page = PaginatedResult::DEFAULT_PAGE,
|
||||
int $limit = PaginatedResult::DEFAULT_LIMIT,
|
||||
?string $search = null,
|
||||
) {
|
||||
$this->page = max(1, $page);
|
||||
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
|
||||
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user