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

@@ -86,7 +86,7 @@ test.describe('Student Management', () => {
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 10000 }),
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
@@ -100,11 +100,11 @@ test.describe('Student Management', () => {
* Waiting for one of these ensures the component is interactive.
*/
async function waitForGuardianSection(page: import('@playwright/test').Page) {
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 15000 });
await expect(
page.getByText(/aucun parent\/tuteur lié/i)
.or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 15000 });
}
// ============================================================================
@@ -151,8 +151,20 @@ test.describe('Student Management', () => {
await waitForGuardianSection(page);
// Clean up guardians if any exist (cross-browser interference: parallel
// browser projects share the same DB, so another browser may have added
// guardians between our beforeAll cleanup and this test).
while (await page.locator('.guardian-item').count() > 0) {
const item = page.locator('.guardian-item').first();
await item.getByRole('button', { name: /retirer/i }).click();
await expect(item.getByText(/confirmer/i)).toBeVisible({ timeout: 5000 });
await item.getByRole('button', { name: /oui/i }).click();
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await page.waitForTimeout(500);
}
// Should show the empty state
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible();
await expect(page.getByText(/aucun parent\/tuteur lié/i)).toBeVisible({ timeout: 15000 });
// The "add guardian" button should be visible
await expect(page.getByRole('button', { name: /ajouter un parent/i })).toBeVisible();
@@ -342,6 +354,12 @@ test.describe('Student Management', () => {
page.locator('.users-table, .empty-state')
).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// The student email should appear in the users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
@@ -355,6 +373,12 @@ test.describe('Student Management', () => {
// Wait for users table
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
// Search for the student user (pagination may hide them beyond page 1)
const searchInput = page.locator('input[type="search"]');
await searchInput.fill(STUDENT_EMAIL);
await page.waitForTimeout(500);
await page.waitForLoadState('networkidle');
// Find the student row and verify role
const studentRow = page.locator('tr', { has: page.locator(`text=${STUDENT_EMAIL}`) });
await expect(studentRow).toContainText(/élève/i);