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,493 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const ADMIN_EMAIL = 'e2e-import-admin@example.com';
const ADMIN_PASSWORD = 'ImportTest123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runCommand(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId, academicYearId };
}
async function loginAsAdmin(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
// Create CSV fixture file for tests
function createCsvFixture(filename: string, content: string): string {
const tmpDir = join(__dirname, 'fixtures');
mkdirSync(tmpDir, { recursive: true });
const filePath = join(tmpDir, filename);
writeFileSync(filePath, content, 'utf-8');
return filePath;
}
test.describe('Student Import via CSV', () => {
test.describe.configure({ mode: 'serial' });
let classId: string;
test.beforeAll(async () => {
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up auto-created class from previous runs (FK: assignments first)
try {
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
} catch { /* ignore */ }
// Create a class for valid import rows
const { schoolId, academicYearId } = resolveDeterministicIds();
const suffix = Date.now().toString().slice(-8);
classId = `00000100-e2e0-4000-8000-${suffix}0001`;
try {
runCommand(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, capacity, status, created_at, updated_at) VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E Import A', NULL, NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
);
} catch {
// Class may already exist
}
});
test('displays the import wizard page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
timeout: 15000
});
// Verify stepper is visible with 4 steps
await expect(page.locator('.stepper .step')).toHaveCount(4);
// Verify dropzone is visible
await expect(page.locator('.dropzone')).toBeVisible();
await expect(page.getByText(/glissez votre fichier/i)).toBeVisible();
});
test('shows format help cards', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
timeout: 15000
});
await expect(page.getByText(/formats supportés/i)).toBeVisible();
await expect(page.getByText('Pronote', { exact: true })).toBeVisible();
await expect(page.getByText('EcoleDirecte', { exact: true })).toBeVisible();
await expect(page.getByText(/personnalisé/i)).toBeVisible();
});
test('uploads a CSV file and shows mapping step', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\nMartin;Marie;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-test.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
// Upload via file input
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(csvPath);
// Should transition to mapping step
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// File info should be visible
await expect(page.getByText(/e2e-import-test\.csv/i)).toBeVisible();
await expect(page.getByText(/2 lignes/i)).toBeVisible();
// Column names should appear in mapping
await expect(page.locator('.column-name').filter({ hasText: /^Nom$/ })).toBeVisible();
await expect(page.locator('.column-name').filter({ hasText: /^Prénom$/ })).toBeVisible();
await expect(page.locator('.column-name').filter({ hasText: /^Classe$/ })).toBeVisible();
// Clean up
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('validates required fields in mapping', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-required.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(csvPath);
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// The mapping should be auto-suggested, so the "Valider le mapping" button should be enabled
const validateButton = page.getByRole('button', { name: /valider le mapping/i });
await expect(validateButton).toBeVisible();
// Clean up
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('navigates back from mapping to upload', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-back.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(csvPath);
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// Click back button
await page.getByRole('button', { name: /retour/i }).click();
// Should be back on upload step
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 10000 });
// Clean up
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('rejects non-CSV files', async ({ page }) => {
const txtPath = createCsvFixture('e2e-import-bad.pdf', 'not a csv file');
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(txtPath);
// Should show error
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
// Clean up
try { unlinkSync(txtPath); } catch { /* ignore */ }
});
test('shows preview step with valid/error counts', async ({ page }) => {
const csvContent =
'Nom;Prénom;Classe\nDupont;Jean;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-preview.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(csvPath);
// Wait for mapping step
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// Submit mapping
await page.getByRole('button', { name: /valider le mapping/i }).click();
// Wait for preview step
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
// Should show valid and error counts
await expect(page.locator('.summary-card.valid')).toBeVisible();
await expect(page.locator('.summary-card.error')).toBeVisible();
// Clean up
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('navigable from students page via import button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
await expect(
page.getByRole('heading', { name: /gestion des élèves/i })
).toBeVisible({ timeout: 15000 });
// Click import link
const importLink = page.getByRole('link', { name: /importer.*csv/i });
await expect(importLink).toBeVisible();
await importLink.click();
// Should navigate to import page
await expect(page.getByRole('heading', { name: /import d'élèves/i })).toBeVisible({
timeout: 15000
});
});
test('[P0] completes full import flow with progress and report', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nTestImport;Alice;E2E Import A\nTestImport;Bob;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
// Step 1: Upload
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Step 2: Mapping
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /valider le mapping/i }).click();
// Step 3: Preview
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /lancer l'import/i }).click();
// Step 4: Confirmation — wait for completion (import may be too fast for progressbar to be visible)
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
// Verify report stats
const stats = page.locator('.report-stats .stat');
const importedStat = stats.filter({ hasText: /importés/ });
await expect(importedStat.locator('.stat-value')).toHaveText('2');
const errorStat = stats.filter({ hasText: /erreurs/ });
await expect(errorStat.locator('.stat-value')).toHaveText('0');
// Verify action buttons
await expect(page.getByRole('button', { name: /télécharger le rapport/i })).toBeVisible();
await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible();
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('[P1] imports only valid rows when errors exist', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nDurand;Sophie;E2E Import A\n;Marie;E2E Import A\nMartin;;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-valid-only.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
// Upload
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Mapping
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /valider le mapping/i }).click();
// Preview
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
// Verify error count
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('2');
// Verify error detail rows are visible
await expect(page.locator('.error-detail').first()).toBeVisible();
// "Import valid only" radio should be selected by default
const validOnlyRadio = page.locator('input[type="radio"][name="importMode"][value="true"]');
await expect(validOnlyRadio).toBeChecked();
// Launch import (should only import 1 valid row)
await page.getByRole('button', { name: /lancer l'import/i }).click();
// Wait for completion
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
// Verify only 1 student imported
const stats = page.locator('.report-stats .stat');
const importedStat = stats.filter({ hasText: /importés/ });
await expect(importedStat.locator('.stat-value')).toHaveText('1');
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('[P1] shows unknown classes and allows auto-creation', async ({ page }) => {
const csvContent = 'Nom;Prénom;Classe\nLemaire;Paul;E2E NewAutoClass\n';
const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
// Upload
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Mapping
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /valider le mapping/i }).click();
// Preview
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
// Verify unknown classes section
await expect(page.locator('.unknown-classes')).toBeVisible();
await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass');
// Check auto-create checkbox
await page.locator('.unknown-classes input[type="checkbox"]').check();
// Select "import all rows" since unknown class makes row invalid (validCount=0)
await page.locator('input[type="radio"][name="importMode"][value="false"]').check();
// Launch import
await page.getByRole('button', { name: /lancer l'import/i }).click();
// Wait for completion
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
// Verify student imported
const stats = page.locator('.report-stats .stat');
const importedStat = stats.filter({ hasText: /importés/ });
await expect(importedStat.locator('.stat-value')).toHaveText('1');
try { unlinkSync(csvPath); } catch { /* ignore */ }
// Cleanup: delete assignments then class (FK constraint)
try {
runCommand(`DELETE FROM class_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass')`);
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
} catch { /* ignore */ }
});
test('[P1] detects Pronote format and pre-fills mapping', async ({ page }) => {
// Pronote format needs 3+ matching columns: Élèves, Né(e) le, Sexe, Classe de rattachement
const csvContent = 'Élèves;Né(e) le;Sexe;Classe de rattachement\nDUPONT Jean;15/03/2010;M;E2E Import A\n';
const csvPath = createCsvFixture('e2e-import-pronote.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Wait for mapping step
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// Verify format detection badge
await expect(page.locator('.format-badge')).toBeVisible();
await expect(page.locator('.format-badge')).toContainText('Pronote');
// Verify pre-filled mapping: Élèves → Nom complet (fullName)
const elevesRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Élèves$/ }) });
await expect(elevesRow.locator('select')).toHaveValue('fullName');
// Verify pre-filled mapping: Classe de rattachement → Classe (className)
const classeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Classe de rattachement$/ }) });
await expect(classeRow.locator('select')).toHaveValue('className');
// Verify pre-filled mapping: Né(e) le → Date de naissance (birthDate)
const dateRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Né\(e\) le$/ }) });
await expect(dateRow.locator('select')).toHaveValue('birthDate');
// Verify pre-filled mapping: Sexe → Genre (gender)
const sexeRow = page.locator('.mapping-row').filter({ has: page.locator('.column-name', { hasText: /^Sexe$/ }) });
await expect(sexeRow.locator('select')).toHaveValue('gender');
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
// Create CSV with 8 data rows (more than the 5-line preview limit)
const csvContent = [
'Nom;Prénom;Classe',
'Alpha;Un;E2E Import A',
'Bravo;Deux;E2E Import A',
'Charlie;Trois;E2E Import A',
'Delta;Quatre;E2E Import A',
'Echo;Cinq;E2E Import A',
'Foxtrot;Six;E2E Import A',
'Golf;Sept;E2E Import A',
'Hotel;Huit;E2E Import A'
].join('\n') + '\n';
const csvPath = createCsvFixture('e2e-import-preview-5.csv', csvContent);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Wait for mapping step
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
// Verify preview section exists
await expect(page.locator('.preview-section')).toBeVisible();
// Verify heading shows 5 premières lignes
await expect(page.locator('.preview-section h3')).toContainText('5 premières lignes');
// Verify exactly 5 rows in the preview table (not 8)
await expect(page.locator('.preview-table tbody tr')).toHaveCount(5);
// Verify total row count in file info
await expect(page.getByText(/8 lignes/i)).toBeVisible();
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
test('[P2] rejects files exceeding 10 MB limit', async ({ page }) => {
// Create a CSV file that exceeds 10 MB
const header = 'Nom;Prénom;Classe\n';
const line = 'Dupont;Jean;E2E Import A\n';
const targetSize = 10 * 1024 * 1024 + 100; // just over 10 MB
const repeats = Math.ceil((targetSize - header.length) / line.length);
const content = header + line.repeat(repeats);
const csvPath = createCsvFixture('e2e-import-too-large.csv', content);
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/import/students`);
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
await page.locator('input[type="file"]').setInputFiles(csvPath);
// Should show error about file size
await expect(page.locator('.alert-error')).toBeVisible({ timeout: 10000 });
await expect(page.getByText(/dépasse la taille maximale de 10 Mo/i)).toBeVisible();
// Should stay on upload step (not transition to mapping)
await expect(page.locator('.dropzone')).toBeVisible();
try { unlinkSync(csvPath); } catch { /* ignore */ }
});
});

View File

@@ -79,6 +79,7 @@ export default tseslint.config(
fetch: 'readonly',
HTMLElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLSelectElement: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
URL: 'readonly',
@@ -88,7 +89,10 @@ export default tseslint.config(
AbortController: 'readonly',
DOMException: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly'
clearTimeout: 'readonly',
DragEvent: 'readonly',
File: 'readonly',
Blob: 'readonly'
}
},
plugins: {

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;