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:
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
interface TeacherAssignment {
|
||||
@@ -14,6 +18,10 @@
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
createdAt: string;
|
||||
teacherFirstName: string | null;
|
||||
teacherLastName: string | null;
|
||||
className: string | null;
|
||||
subjectName: string | null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -48,6 +56,13 @@
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Create modal
|
||||
let showCreateModal = $state(false);
|
||||
let selectedTeacherId = $state('');
|
||||
@@ -61,8 +76,8 @@
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Load everything on mount
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
$effect(() => {
|
||||
untrack(() => loadAll());
|
||||
});
|
||||
|
||||
async function loadAll() {
|
||||
@@ -71,11 +86,11 @@
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
|
||||
// Load reference data in parallel
|
||||
// Load reference data and assignments in parallel
|
||||
const [teachersRes, classesRes, subjectsRes] = await Promise.all([
|
||||
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`),
|
||||
authenticatedFetch(`${apiUrl}/classes`),
|
||||
authenticatedFetch(`${apiUrl}/subjects`)
|
||||
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF&itemsPerPage=100`),
|
||||
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
|
||||
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`)
|
||||
]);
|
||||
|
||||
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
|
||||
@@ -92,7 +107,6 @@
|
||||
classes = extractCollection(classesData);
|
||||
subjects = extractCollection(subjectsData);
|
||||
|
||||
// Load assignments for each class in parallel
|
||||
await loadAssignments();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
@@ -101,28 +115,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignments() {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
let assignmentsAbortController: AbortController | null = null;
|
||||
|
||||
if (classes.length === 0) {
|
||||
assignments = [];
|
||||
return;
|
||||
async function loadAssignments() {
|
||||
assignmentsAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
assignmentsAbortController = controller;
|
||||
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const response = await authenticatedFetch(`${apiUrl}/teacher-assignments?${params.toString()}`, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des affectations');
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
classes.map(async (cls) => {
|
||||
try {
|
||||
const res = await authenticatedFetch(`${apiUrl}/classes/${cls.id}/teachers`);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return extractCollection(data) as TeacherAssignment[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
assignments = results.flat();
|
||||
const data = await response.json();
|
||||
assignments = data['hydra:member'] ?? data['member'] ?? [];
|
||||
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? assignments.length;
|
||||
}
|
||||
|
||||
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||
@@ -134,26 +149,44 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
function getTeacherName(teacherId: string): string {
|
||||
const teacher = teachers.find((t) => t.id === teacherId);
|
||||
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
|
||||
}
|
||||
|
||||
function getClassName(classId: string): string {
|
||||
const cls = classes.find((c) => c.id === classId);
|
||||
return cls ? cls.name : classId;
|
||||
}
|
||||
|
||||
function getSubjectName(subjectId: string): string {
|
||||
const subject = subjects.find((s) => s.id === subjectId);
|
||||
return subject ? subject.name : subjectId;
|
||||
}
|
||||
|
||||
function getSubjectColor(subjectId: string): string | null {
|
||||
const subject = subjects.find((s) => s.id === subjectId);
|
||||
return subject?.color ?? null;
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
reloadAssignments();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
updateUrl();
|
||||
reloadAssignments();
|
||||
}
|
||||
|
||||
async function reloadAssignments() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
await loadAssignments();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
showCreateModal = true;
|
||||
selectedTeacherId = '';
|
||||
@@ -194,7 +227,7 @@
|
||||
|
||||
successMessage = 'Affectation créée avec succès';
|
||||
closeCreateModal();
|
||||
await loadAssignments();
|
||||
await reloadAssignments();
|
||||
globalThis.setTimeout(() => {
|
||||
successMessage = null;
|
||||
}, 3000);
|
||||
@@ -237,7 +270,7 @@
|
||||
|
||||
successMessage = 'Affectation retirée avec succès';
|
||||
closeDeleteModal();
|
||||
await loadAssignments();
|
||||
await reloadAssignments();
|
||||
globalThis.setTimeout(() => {
|
||||
successMessage = null;
|
||||
}, 3000);
|
||||
@@ -247,9 +280,6 @@
|
||||
isDeleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only show active assignments
|
||||
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -282,17 +312,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher par enseignant, classe, matière..."
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des affectations...</p>
|
||||
</div>
|
||||
{:else if activeAssignments.length === 0}
|
||||
{:else if assignments.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">📋</span>
|
||||
<h2>Aucune affectation</h2>
|
||||
<p>Commencez par affecter un enseignant à une classe et une matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Nouvelle affectation</button>
|
||||
{#if searchTerm}
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucune affectation ne correspond à votre recherche</p>
|
||||
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
|
||||
{:else}
|
||||
<h2>Aucune affectation</h2>
|
||||
<p>Commencez par affecter un enseignant à une classe et une matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Nouvelle affectation</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="table-container">
|
||||
@@ -308,22 +350,22 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each activeAssignments as assignment (assignment.id)}
|
||||
{#each assignments as assignment (assignment.id)}
|
||||
<tr>
|
||||
<td class="teacher-cell">
|
||||
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
|
||||
<span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span>
|
||||
</td>
|
||||
<td>{getClassName(assignment.classId)}</td>
|
||||
<td>{assignment.className}</td>
|
||||
<td>
|
||||
{#if getSubjectColor(assignment.subjectId)}
|
||||
<span
|
||||
class="subject-badge"
|
||||
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
|
||||
>
|
||||
{getSubjectName(assignment.subjectId)}
|
||||
{assignment.subjectName}
|
||||
</span>
|
||||
{:else}
|
||||
{getSubjectName(assignment.subjectId)}
|
||||
{assignment.subjectName}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
@@ -345,6 +387,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -449,9 +492,9 @@
|
||||
|
||||
<div class="modal-body">
|
||||
<p id="delete-modal-description">
|
||||
Retirer <strong>{getTeacherName(assignmentToDelete.teacherId)}</strong>
|
||||
de <strong>{getSubjectName(assignmentToDelete.subjectId)}</strong>
|
||||
en <strong>{getClassName(assignmentToDelete.classId)}</strong> ?
|
||||
Retirer <strong>{assignmentToDelete.teacherFirstName} {assignmentToDelete.teacherLastName}</strong>
|
||||
de <strong>{assignmentToDelete.subjectName}</strong>
|
||||
en <strong>{assignmentToDelete.className}</strong> ?
|
||||
</p>
|
||||
<p class="delete-warning">
|
||||
Les notes existantes seront conservées, mais l'enseignant ne pourra plus en ajouter.
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
interface SchoolClass {
|
||||
@@ -23,6 +27,13 @@
|
||||
let showDeleteModal = $state(false);
|
||||
let classToDelete = $state<SchoolClass | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Form state
|
||||
let newClassName = $state('');
|
||||
let newClassLevel = $state<string | null>(null);
|
||||
@@ -31,33 +42,70 @@
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Load classes on mount
|
||||
let loadAbortController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
loadClasses();
|
||||
untrack(() => loadClasses());
|
||||
});
|
||||
|
||||
async function loadClasses() {
|
||||
loadAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortController = controller;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/classes`);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const url = `${apiUrl}/classes?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des classes');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API Platform peut retourner hydra:member, member, ou un tableau direct
|
||||
classes = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? classes.length;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
// Use demo data for now
|
||||
classes = [];
|
||||
totalItems = 0;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (!controller.signal.aborted) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadClasses();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
updateUrl();
|
||||
loadClasses();
|
||||
}
|
||||
|
||||
async function handleCreateClass() {
|
||||
if (!newClassName.trim()) return;
|
||||
|
||||
@@ -165,17 +213,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher par nom, niveau..."
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des classes...</p>
|
||||
</div>
|
||||
{:else if classes.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">🏫</span>
|
||||
<h2>Aucune classe</h2>
|
||||
<p>Commencez par créer votre première classe</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une classe</button>
|
||||
{#if searchTerm}
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucune classe ne correspond à votre recherche</p>
|
||||
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
|
||||
{:else}
|
||||
<h2>Aucune classe</h2>
|
||||
<p>Commencez par créer votre première classe</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une classe</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="classes-grid">
|
||||
@@ -212,6 +272,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
interface Subject {
|
||||
@@ -37,6 +41,13 @@
|
||||
let showDeleteModal = $state(false);
|
||||
let subjectToDelete = $state<Subject | null>(null);
|
||||
|
||||
// Pagination & Search
|
||||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Form state
|
||||
let newSubjectName = $state('');
|
||||
let newSubjectCode = $state('');
|
||||
@@ -45,32 +56,70 @@
|
||||
let isDeleting = $state(false);
|
||||
|
||||
// Load subjects on mount
|
||||
let loadAbortController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
loadSubjects();
|
||||
untrack(() => loadSubjects());
|
||||
});
|
||||
|
||||
async function loadSubjects() {
|
||||
loadAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortController = controller;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/subjects`);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const url = `${apiUrl}/subjects?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des matières');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// API Platform peut retourner hydra:member, member, ou un tableau direct
|
||||
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? subjects.length;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
subjects = [];
|
||||
totalItems = 0;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (!controller.signal.aborted) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadSubjects();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
updateUrl();
|
||||
loadSubjects();
|
||||
}
|
||||
|
||||
async function handleCreateSubject() {
|
||||
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
|
||||
|
||||
@@ -216,17 +265,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher par nom, code..."
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des matières...</p>
|
||||
</div>
|
||||
{:else if subjects.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">📚</span>
|
||||
<h2>Aucune matière</h2>
|
||||
<p>Commencez par créer votre première matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
|
||||
{#if searchTerm}
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucune matière ne correspond à votre recherche</p>
|
||||
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
|
||||
{:else}
|
||||
<h2>Aucune matière</h2>
|
||||
<p>Commencez par créer votre première matière</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="subjects-grid">
|
||||
@@ -268,6 +329,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch, getCurrentUserId } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { updateUserRoles } from '$features/roles/api/roles';
|
||||
|
||||
// Types
|
||||
@@ -49,8 +54,15 @@
|
||||
let showCreateModal = $state(false);
|
||||
|
||||
// Filters
|
||||
let filterRole = $state<string>('');
|
||||
let filterStatut = $state<string>('');
|
||||
let filterRole = $state<string>(page.url.searchParams.get('role') ?? '');
|
||||
let filterStatut = $state<string>(page.url.searchParams.get('statut') ?? '');
|
||||
|
||||
// Pagination & Search
|
||||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||||
let totalItems = $state(0);
|
||||
const itemsPerPage = 30;
|
||||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||||
|
||||
// Form state
|
||||
let newFirstName = $state('');
|
||||
@@ -74,21 +86,31 @@
|
||||
let isSavingRoles = $state(false);
|
||||
|
||||
// Load users on mount
|
||||
let loadAbortController: AbortController | null = null;
|
||||
|
||||
$effect(() => {
|
||||
loadUsers();
|
||||
untrack(() => loadUsers());
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
loadAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadAbortController = controller;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('itemsPerPage', String(itemsPerPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
if (filterRole) params.set('role', filterRole);
|
||||
if (filterStatut) params.set('statut', filterStatut);
|
||||
const queryString = params.toString();
|
||||
const url = `${apiUrl}/users${queryString ? `?${queryString}` : ''}`;
|
||||
const response = await authenticatedFetch(url);
|
||||
const url = `${apiUrl}/users?${params.toString()}`;
|
||||
const response = await authenticatedFetch(url, { signal: controller.signal });
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des utilisateurs');
|
||||
@@ -96,21 +118,54 @@
|
||||
|
||||
const data = await response.json();
|
||||
users = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
|
||||
totalItems = data['hydra:totalItems'] ?? data['totalItems'] ?? users.length;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
users = [];
|
||||
totalItems = 0;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
if (!controller.signal.aborted) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (currentPage > 1) params.set('page', String(currentPage));
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
if (filterRole) params.set('role', filterRole);
|
||||
if (filterStatut) params.set('statut', filterStatut);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleSearch(value: string) {
|
||||
searchTerm = value;
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
currentPage = newPage;
|
||||
updateUrl();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filterRole = '';
|
||||
filterStatut = '';
|
||||
searchTerm = '';
|
||||
currentPage = 1;
|
||||
updateUrl();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
@@ -474,17 +529,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onSearch={handleSearch}
|
||||
placeholder="Rechercher par nom, email..."
|
||||
/>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state">
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des utilisateurs...</p>
|
||||
</div>
|
||||
{:else if users.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">👥</span>
|
||||
<h2>Aucun utilisateur</h2>
|
||||
<p>Commencez par inviter votre premier utilisateur</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Inviter un utilisateur</button>
|
||||
{#if searchTerm || filterRole || filterStatut}
|
||||
<h2>Aucun résultat</h2>
|
||||
<p>Aucun utilisateur ne correspond à vos critères de recherche</p>
|
||||
<button class="btn-secondary" onclick={resetFilters}>Réinitialiser les filtres</button>
|
||||
{:else}
|
||||
<h2>Aucun utilisateur</h2>
|
||||
<p>Commencez par inviter votre premier utilisateur</p>
|
||||
<button class="btn-primary" onclick={openCreateModal}>Inviter un utilisateur</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="users-table-container">
|
||||
@@ -577,6 +644,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user