feat: Permettre la création manuelle d'élèves et leur affectation aux classes

Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).

L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).

Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
This commit is contained in:
2026-02-23 19:12:21 +01:00
parent e5203097ef
commit 560b941821
49 changed files with 5184 additions and 65 deletions

View File

@@ -36,6 +36,11 @@
<span class="action-label">Configurer les classes</span>
<span class="action-hint">Créer et gérer</span>
</a>
<a class="action-card" href="/admin/students">
<span class="action-icon">🎒</span>
<span class="action-label">Gérer les élèves</span>
<span class="action-hint">Inscrire et affecter</span>
</a>
<a class="action-card" href="/admin/subjects">
<span class="action-icon">📚</span>
<span class="action-label">Gérer les matières</span>

View File

@@ -0,0 +1,150 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface Student {
id: string;
firstName: string;
lastName: string;
email: string | null;
classId: string | null;
className: string | null;
classLevel: string | null;
statut: string;
studentNumber: string | null;
dateNaissance: string | null;
}
export interface SchoolClass {
id: string;
name: string;
level: string | null;
}
export interface FetchStudentsParams {
page: number;
itemsPerPage: number;
search?: string | undefined;
classId?: string | undefined;
signal?: AbortSignal | undefined;
}
export interface FetchStudentsResult {
members: Student[];
totalItems: number;
}
export interface CreateStudentData {
firstName: string;
lastName: string;
classId: string;
email?: string | undefined;
dateNaissance?: string | undefined;
studentNumber?: string | undefined;
}
/**
* Récupère la liste paginée des élèves.
*/
export async function fetchStudents(params: FetchStudentsParams): Promise<FetchStudentsResult> {
const apiUrl = getApiBaseUrl();
const searchParams = new URLSearchParams();
searchParams.set('page', String(params.page));
searchParams.set('itemsPerPage', String(params.itemsPerPage));
if (params.search) searchParams.set('search', params.search);
if (params.classId) searchParams.set('classId', params.classId);
const options: RequestInit = {};
if (params.signal) options.signal = params.signal;
const response = await authenticatedFetch(
`${apiUrl}/students?${searchParams.toString()}`,
options
);
if (!response.ok) {
throw new Error('Erreur lors du chargement des élèves');
}
const data = await response.json();
const members: Student[] =
data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
const totalItems: number = data['hydra:totalItems'] ?? data['totalItems'] ?? members.length;
return { members, totalItems };
}
/**
* Récupère la liste des classes disponibles.
*/
export async function fetchClasses(): Promise<SchoolClass[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/classes?itemsPerPage=200`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des classes');
}
const data = await response.json();
return data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
}
/**
* Crée un nouvel élève.
*/
export async function createStudent(studentData: CreateStudentData): Promise<Student> {
const apiUrl = getApiBaseUrl();
const body: Record<string, string> = {
firstName: studentData.firstName,
lastName: studentData.lastName,
classId: studentData.classId
};
if (studentData.email) body['email'] = studentData.email;
if (studentData.dateNaissance) body['dateNaissance'] = studentData.dateNaissance;
if (studentData.studentNumber) body['studentNumber'] = studentData.studentNumber;
const response = await authenticatedFetch(`${apiUrl}/students`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!response.ok) {
let errorMessage = `Erreur lors de la création (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
else if (errorData.message) errorMessage = errorData.message;
else if (errorData.detail) errorMessage = errorData.detail;
} catch {
// JSON parsing failed
}
throw new Error(errorMessage);
}
return await response.json();
}
/**
* Change la classe d'un élève.
*/
export async function changeStudentClass(studentId: string, newClassId: string): Promise<void> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/class`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({ classId: newClassId })
});
if (!response.ok) {
let errorMessage = `Erreur lors du changement de classe (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) errorMessage = errorData['hydra:description'];
else if (errorData.message) errorMessage = errorData.message;
else if (errorData.detail) errorMessage = errorData.detail;
} catch {
// JSON parsing failed
}
throw new Error(errorMessage);
}
}

View File

@@ -25,6 +25,7 @@
const navLinks = [
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
@@ -81,6 +82,7 @@
// Determine which admin section is active
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
<div class="student-detail">
<header class="page-header">
<a href="/admin/users" class="back-link">&larr; Retour</a>
<a href="/admin/students" class="back-link">&larr; Retour</a>
<h1>Fiche élève</h1>
</header>