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:
2026-02-15 13:54:51 +01:00
parent 88e7f319db
commit 76e16db0d8
57 changed files with 3123 additions and 181 deletions

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetClasses\GetClassesHandler;
use App\Administration\Application\Query\GetClasses\GetClassesQuery;
@@ -12,13 +13,14 @@ use App\Administration\Infrastructure\Api\Resource\ClassResource;
use App\Administration\Infrastructure\Security\ClassVoter;
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
use App\Shared\Infrastructure\Tenant\TenantContext;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des classes.
* State Provider pour récupérer la liste des classes avec pagination.
*
* @implements ProviderInterface<ClassResource>
*/
@@ -32,13 +34,9 @@ final readonly class ClassCollectionProvider implements ProviderInterface
) {
}
/**
* @return ClassResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(ClassVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les classes.');
}
@@ -48,20 +46,28 @@ final readonly class ClassCollectionProvider implements ProviderInterface
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$academicYearId = $this->academicYearResolver->resolve('current') ?? '';
if ($academicYearId === '') {
return [];
return new TraversablePaginator(new ArrayIterator([]), $page, $itemsPerPage, 0);
}
$query = new GetClassesQuery(
tenantId: $tenantId,
academicYearId: $academicYearId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$classDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(
$resources = array_map(
static function ($dto) {
$resource = new ClassResource();
$resource->id = $dto->id;
@@ -75,7 +81,14 @@ final readonly class ClassCollectionProvider implements ProviderInterface
return $resource;
},
$classDtos,
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetSubjects\GetSubjectsHandler;
use App\Administration\Application\Query\GetSubjects\GetSubjectsQuery;
@@ -15,13 +16,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des matières.
* State Provider pour récupérer la liste des matières avec pagination.
*
* @implements ProviderInterface<SubjectResource>
*/
@@ -35,13 +37,9 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
) {
}
/**
* @return SubjectResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
// Vérifier les permissions de lecture (sans sujet spécifique)
if (!$this->authorizationChecker->isGranted(SubjectVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les matières.');
}
@@ -52,14 +50,29 @@ final readonly class SubjectCollectionProvider implements ProviderInterface
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
$schoolId = $this->schoolIdResolver->resolveForTenant($tenantId);
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetSubjectsQuery(
tenantId: $tenantId,
schoolId: $schoolId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$subjectDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(SubjectResource::fromDto(...), $subjectDtos);
$resources = array_map(SubjectResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsHandler;
use App\Administration\Application\Query\GetAllAssignments\GetAllAssignmentsQuery;
use App\Administration\Infrastructure\Api\Resource\TeacherAssignmentResource;
use App\Administration\Infrastructure\Security\TeacherAssignmentVoter;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer toutes les affectations avec pagination et recherche.
*
* @implements ProviderInterface<TeacherAssignmentResource>
*/
final readonly class TeacherAssignmentsCollectionProvider implements ProviderInterface
{
public function __construct(
private GetAllAssignmentsHandler $handler,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(TeacherAssignmentVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les affectations.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = (string) $this->tenantContext->getCurrentTenantId();
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetAllAssignmentsQuery(
tenantId: $tenantId,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$result = ($this->handler)($query);
$resources = array_map(
static function (AssignmentWithNamesDto $dto) {
$resource = new TeacherAssignmentResource();
$resource->id = $dto->id;
$resource->teacherId = $dto->teacherId;
$resource->classId = $dto->classId;
$resource->subjectId = $dto->subjectId;
$resource->academicYearId = $dto->academicYearId;
$resource->status = $dto->status;
$resource->startDate = $dto->startDate;
$resource->endDate = $dto->endDate;
$resource->createdAt = $dto->createdAt;
$resource->teacherFirstName = $dto->teacherFirstName;
$resource->teacherLastName = $dto->teacherLastName;
$resource->className = $dto->className;
$resource->subjectName = $dto->subjectName;
return $resource;
},
$result->items,
);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\TraversablePaginator;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Application\Query\GetUsers\GetUsersHandler;
use App\Administration\Application\Query\GetUsers\GetUsersQuery;
@@ -14,13 +15,14 @@ use App\Shared\Infrastructure\Tenant\TenantContext;
use function array_map;
use ArrayIterator;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la liste des utilisateurs avec filtres.
* State Provider pour récupérer la liste des utilisateurs avec filtres et pagination.
*
* @implements ProviderInterface<UserResource>
*/
@@ -33,11 +35,8 @@ final readonly class UserCollectionProvider implements ProviderInterface
) {
}
/**
* @return UserResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): TraversablePaginator
{
if (!$this->authorizationChecker->isGranted(UserVoter::VIEW)) {
throw new AccessDeniedHttpException('Vous n\'êtes pas autorisé à voir les utilisateurs.');
@@ -51,14 +50,27 @@ final readonly class UserCollectionProvider implements ProviderInterface
/** @var array<string, string> $filters */
$filters = $context['filters'] ?? [];
$page = (int) ($filters['page'] ?? 1);
$itemsPerPage = (int) ($filters['itemsPerPage'] ?? 30);
$query = new GetUsersQuery(
tenantId: $tenantId,
role: isset($filters['role']) ? (string) $filters['role'] : null,
statut: isset($filters['statut']) ? (string) $filters['statut'] : null,
page: $page,
limit: $itemsPerPage,
search: isset($filters['search']) ? (string) $filters['search'] : null,
);
$userDtos = ($this->handler)($query);
$result = ($this->handler)($query);
return array_map(UserResource::fromDto(...), $userDtos);
$resources = array_map(UserResource::fromDto(...), $result->items);
return new TraversablePaginator(
new ArrayIterator($resources),
$page,
$itemsPerPage,
$result->total,
);
}
}

View File

@@ -17,6 +17,7 @@ use App\Administration\Infrastructure\Api\Processor\RemoveTeacherAssignmentProce
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentItemProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByClassProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsByTeacherProvider;
use App\Administration\Infrastructure\Api\Provider\TeacherAssignmentsCollectionProvider;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
@@ -29,6 +30,11 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'TeacherAssignment',
operations: [
new GetCollection(
uriTemplate: '/teacher-assignments',
provider: TeacherAssignmentsCollectionProvider::class,
name: 'get_all_teacher_assignments',
),
new GetCollection(
uriTemplate: '/teachers/{teacherId}/assignments',
uriVariables: [
@@ -90,6 +96,14 @@ final class TeacherAssignmentResource
public ?DateTimeImmutable $createdAt = null;
public ?string $teacherFirstName = null;
public ?string $teacherLastName = null;
public ?string $className = null;
public ?string $subjectName = null;
public static function fromDomain(TeacherAssignment $assignment): self
{
$resource = new self();

View File

@@ -130,6 +130,23 @@ final readonly class DoctrineClassRepository implements ClassRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM school_classes
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => ClassStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -129,6 +129,23 @@ final readonly class DoctrineSubjectRepository implements SubjectRepository
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM subjects
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY name ASC',
[
'tenant_id' => (string) $tenantId,
'status' => SubjectStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -197,6 +197,23 @@ final readonly class DoctrineTeacherAssignmentRepository implements TeacherAssig
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT * FROM teacher_assignments
WHERE tenant_id = :tenant_id
AND status = :status
ORDER BY created_at ASC',
[
'tenant_id' => (string) $tenantId,
'status' => AssignmentStatus::ACTIVE->value,
],
);
return array_map(fn ($row) => $this->hydrate($row), $rows);
}
/**
* @param array<string, mixed> $row
*/

View File

@@ -82,6 +82,22 @@ final class InMemoryClassRepository implements ClassRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $class) {
if ($class->tenantId->equals($tenantId)
&& $class->status === ClassStatus::ACTIVE
) {
$result[] = $class;
}
}
return $result;
}
#[Override]
public function delete(ClassId $id): void
{

View File

@@ -99,6 +99,22 @@ final class InMemorySubjectRepository implements SubjectRepository
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $subject) {
if ($subject->tenantId->equals($tenantId)
&& $subject->status === SubjectStatus::ACTIVE
) {
$result[] = $subject;
}
}
return $result;
}
#[Override]
public function delete(SubjectId $id): void
{

View File

@@ -134,4 +134,20 @@ final class InMemoryTeacherAssignmentRepository implements TeacherAssignmentRepo
return $result;
}
#[Override]
public function findAllActiveByTenant(TenantId $tenantId): array
{
$result = [];
foreach ($this->byId as $assignment) {
if ($assignment->tenantId->equals($tenantId)
&& $assignment->status === AssignmentStatus::ACTIVE
) {
$result[] = $assignment;
}
}
return $result;
}
}