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:
@@ -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>
|
||||
|
||||
150
frontend/src/lib/features/students/api/students.ts
Normal file
150
frontend/src/lib/features/students/api/students.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
1292
frontend/src/routes/admin/students/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="student-detail">
|
||||
<header class="page-header">
|
||||
<a href="/admin/users" class="back-link">← Retour</a>
|
||||
<a href="/admin/students" class="back-link">← Retour</a>
|
||||
<h1>Fiche élève</h1>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user