feat: Permettre l'import d'élèves via fichier CSV ou XLSX

L'import manuel élève par élève est fastidieux pour les établissements
qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes
(upload → mapping → preview → confirmation) permet de traiter un
fichier complet en une seule opération, avec détection automatique
du format (Pronote, École Directe) et validation avant import.

L'import est traité de manière asynchrone via Messenger pour ne pas
bloquer l'interface, avec suivi de progression en temps réel et
réutilisation des mappings entre imports successifs.
This commit is contained in:
2026-02-25 16:51:13 +01:00
parent 560b941821
commit 2420e35492
62 changed files with 7510 additions and 86 deletions

View File

@@ -0,0 +1,186 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
// === Types ===
export interface UploadResult {
id: string;
filename: string;
totalRows: number;
columns: string[];
detectedFormat: string;
suggestedMapping: Record<string, string>;
preview: PreviewRow[];
}
export interface PreviewRow {
line: number;
data: Record<string, string>;
valid: boolean;
errors: RowError[];
}
export interface RowError {
column: string;
message: string;
}
export interface MappingResult {
id: string;
mapping: Record<string, string>;
totalRows: number;
}
export interface PreviewResult {
id: string;
totalRows: number;
validCount: number;
errorCount: number;
rows: PreviewRow[];
unknownClasses: string[];
}
export interface ConfirmResult {
id: string;
status: string;
message: string;
}
export interface ImportStatus {
id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
totalRows: number;
importedCount: number;
errorCount: number;
progression: number;
completedAt: string | null;
}
export interface ImportReport {
id: string;
status: string;
totalRows: number;
importedCount: number;
errorCount: number;
report: string[];
errors: { line: number; errors: RowError[] }[];
}
// === API Functions ===
/**
* Upload un fichier CSV ou XLSX pour l'import d'élèves.
*/
export async function uploadFile(file: File): Promise<UploadResult> {
const apiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append('file', file);
const response = await authenticatedFetch(`${apiUrl}/import/students/upload`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors de l\'upload'
);
}
return await response.json();
}
/**
* Applique le mapping des colonnes.
*/
export async function applyMapping(
batchId: string,
mapping: Record<string, string>,
format: string
): Promise<MappingResult> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/mapping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mapping, format })
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? 'Erreur lors du mapping'
);
}
return await response.json();
}
/**
* Récupère la preview avec validation.
*/
export async function fetchPreview(batchId: string): Promise<PreviewResult> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/preview`);
if (!response.ok) {
throw new Error('Erreur lors de la validation');
}
return await response.json();
}
/**
* Confirme et lance l'import.
*/
export async function confirmImport(
batchId: string,
options: { createMissingClasses: boolean; importValidOnly: boolean }
): Promise<ConfirmResult> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options)
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
data?.['hydra:description'] ??
data?.message ??
data?.detail ??
'Erreur lors de la confirmation'
);
}
return await response.json();
}
/**
* Récupère le statut et la progression de l'import.
*/
export async function fetchImportStatus(batchId: string): Promise<ImportStatus> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/status`);
if (!response.ok) {
throw new Error('Erreur lors de la récupération du statut');
}
return await response.json();
}
/**
* Récupère le rapport détaillé de l'import.
*/
export async function fetchImportReport(batchId: string): Promise<ImportReport> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/import/students/${batchId}/report`);
if (!response.ok) {
throw new Error('Erreur lors de la récupération du rapport');
}
return await response.json();
}

File diff suppressed because it is too large Load Diff

View File

@@ -389,10 +389,15 @@
<h1>Gestion des élèves</h1>
<p class="subtitle">Créez et gérez les élèves de votre établissement</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvel élève
</button>
<div class="header-actions">
<a href="/admin/import/students" class="btn-secondary">
Importer (CSV)
</a>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvel élève
</button>
</div>
</header>
{#if error}
@@ -730,6 +735,12 @@
flex-wrap: wrap;
}
.header-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;