feat: Permettre l'import d'enseignants via fichier CSV ou XLSX
L'établissement a besoin d'importer en masse ses enseignants depuis les exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping → aperçu → import) réutilise l'architecture de l'import élèves tout en ajoutant la gestion des matières et des classes enseignées. Corrections de la review #2 intégrées : - La commande ImportTeachersCommand est routée en async via Messenger pour ne pas bloquer la requête HTTP sur les gros fichiers. - Le handler est protégé par un try/catch Throwable pour marquer le batch en échec si une erreur inattendue survient, évitant qu'il reste bloqué en statut "processing". - Les domain events (UtilisateurInvite) sont dispatchés sur l'event bus après chaque création d'utilisateur, déclenchant l'envoi des emails d'invitation. - L'option "mettre à jour les enseignants existants" (AC5) permet de choisir entre ignorer ou mettre à jour nom/prénom et ajouter les affectations manquantes pour les doublons détectés par email.
This commit is contained in:
@@ -300,15 +300,17 @@ test.describe('Dashboard', () => {
|
||||
await expect(pedagogyLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('import action is disabled (bientot disponible)', async ({ page }) => {
|
||||
test('shows import action cards for students and teachers', async ({ page }) => {
|
||||
await goToDashboard(page);
|
||||
await switchToDemoRole(page, 'Admin');
|
||||
|
||||
await expect(page.getByText(/importer des données/i)).toBeVisible();
|
||||
await expect(page.getByText(/bientôt disponible/i)).toBeVisible();
|
||||
const studentImport = page.getByRole('link', { name: /importer des élèves/i });
|
||||
await expect(studentImport).toBeVisible();
|
||||
await expect(studentImport).toHaveAttribute('href', '/admin/import/students');
|
||||
|
||||
const importCard = page.locator('.action-card.disabled');
|
||||
await expect(importCard).toBeVisible();
|
||||
const teacherImport = page.getByRole('link', { name: /importer des enseignants/i });
|
||||
await expect(teacherImport).toBeVisible();
|
||||
await expect(teacherImport).toHaveAttribute('href', '/admin/import/teachers');
|
||||
});
|
||||
|
||||
test('shows placeholder sections for admin stats', async ({ page }) => {
|
||||
|
||||
@@ -265,7 +265,8 @@ test.describe('Student Import via CSV', () => {
|
||||
});
|
||||
|
||||
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 suffix = Date.now().toString().slice(-6);
|
||||
const csvContent = `Nom;Prénom;Classe\nTestImport${suffix};Alice;E2E Import A\nTestImport${suffix};Bob;E2E Import A\n`;
|
||||
const csvPath = createCsvFixture('e2e-import-full-flow.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
@@ -298,10 +299,17 @@ test.describe('Student Import via CSV', () => {
|
||||
await expect(page.getByRole('button', { name: /voir les élèves/i })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup imported students to avoid cross-run duplicate detection
|
||||
try {
|
||||
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}')`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'TestImport${suffix}'`);
|
||||
} 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 suffix = Date.now().toString().slice(-6);
|
||||
const csvContent = `Nom;Prénom;Classe\nDurand${suffix};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);
|
||||
@@ -340,10 +348,17 @@ test.describe('Student Import via CSV', () => {
|
||||
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}')`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Durand${suffix}'`);
|
||||
} 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 suffix = Date.now().toString().slice(-6);
|
||||
const csvContent = `Nom;Prénom;Classe\nLemaire${suffix};Paul;E2E NewAutoClass\n`;
|
||||
const csvPath = createCsvFixture('e2e-import-auto-class.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
@@ -364,13 +379,11 @@ test.describe('Student Import via CSV', () => {
|
||||
await expect(page.locator('.unknown-classes')).toBeVisible();
|
||||
await expect(page.locator('.class-tag')).toContainText('E2E NewAutoClass');
|
||||
|
||||
// Check auto-create checkbox
|
||||
// Check auto-create checkbox — this resolves class errors,
|
||||
// so the adjusted preview shows all rows as valid and the import button is enabled
|
||||
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
|
||||
// Launch import (no need for radio — adjustedValidCount is now 1)
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Wait for completion
|
||||
@@ -383,9 +396,10 @@ test.describe('Student Import via CSV', () => {
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: delete assignments then class (FK constraint)
|
||||
// Cleanup: delete assignments, users, 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 users WHERE tenant_id = '${TENANT_ID}' AND last_name = 'Lemaire${suffix}'`);
|
||||
runCommand(`DELETE FROM school_classes WHERE tenant_id = '${TENANT_ID}' AND name = 'E2E NewAutoClass'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
@@ -490,4 +504,88 @@ test.describe('Student Import via CSV', () => {
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] clicking the dropzone opens the file picker', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the dropzone and verify the file chooser opens
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser', { timeout: 5000 }),
|
||||
page.locator('.dropzone').click()
|
||||
]);
|
||||
|
||||
// The file chooser was triggered — verify it accepts csv/xlsx
|
||||
expect(fileChooser).toBeTruthy();
|
||||
});
|
||||
|
||||
test('[P1] detects duplicate students in preview when re-importing same file', async ({ page }) => {
|
||||
// First import: create students
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const csvContent = `Nom;Prénom;Classe\nDupliTest${suffix};Alice;E2E Import A\nDupliTest${suffix};Bob;E2E Import A\n`;
|
||||
const csvPath = createCsvFixture('e2e-import-dupli-first.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
// Upload → Mapping → Preview → Confirm (first import)
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Second import: same students should be detected as duplicates
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should show duplicates detected
|
||||
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('2');
|
||||
|
||||
// All rows should be in error (duplicates)
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('0');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: remove imported students
|
||||
try {
|
||||
runCommand(`DELETE FROM class_assignments WHERE user_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}')`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND last_name LIKE 'DupliTest${suffix}'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] detects intra-file duplicate students in preview', async ({ page }) => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const csvContent = `Nom;Prénom;Classe\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Alice;E2E Import A\nIntraTest${suffix};Bob;E2E Import A\n`;
|
||||
const csvPath = createCsvFixture('e2e-import-intra-dupli.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/students`);
|
||||
|
||||
// Upload → Mapping → Preview
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
await page.locator('input[type="file"]').setInputFiles(csvPath);
|
||||
await expect(page.getByText(/association des colonnes/i)).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /valider le mapping/i }).click();
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Should detect 1 intra-file duplicate (second Alice)
|
||||
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1');
|
||||
|
||||
// 2 valid (first Alice + Bob), 1 error (second Alice)
|
||||
await expect(page.locator('.summary-card.valid .summary-number')).toHaveText('2');
|
||||
await expect(page.locator('.summary-card.error .summary-number')).toHaveText('1');
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
});
|
||||
|
||||
487
frontend/e2e/teacher-import.spec.ts
Normal file
487
frontend/e2e/teacher-import.spec.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
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-teacher-import-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'TeacherImportTest123';
|
||||
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()
|
||||
]);
|
||||
}
|
||||
|
||||
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('Teacher Import via CSV', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let subjectId: 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' }
|
||||
);
|
||||
|
||||
const { schoolId } = resolveDeterministicIds();
|
||||
const suffix = Date.now().toString().slice(-8);
|
||||
subjectId = `00000200-e2e0-4000-8000-${suffix}0001`;
|
||||
|
||||
// Create a subject for valid import rows
|
||||
try {
|
||||
runCommand(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E Maths', 'MATH', NULL, 'active', NOW(), NOW()) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// Subject may already exist
|
||||
}
|
||||
|
||||
// Clean up auto-created subjects from previous runs
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE subject_id IN (SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%')`);
|
||||
runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name LIKE 'E2E AutoSubject%'`);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Clean up saved column mappings from previous runs to avoid stale mapping suggestions
|
||||
try {
|
||||
runCommand(`DELETE FROM saved_teacher_column_mappings WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('displays the import wizard page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /import d'enseignants/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('uploads a CSV file and shows mapping step', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean.dupont@ecole.fr\nMartin;Marie;marie.martin@ecole.fr\n';
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-test.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
await expect(page.locator('.dropzone')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
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-teacher-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: /^Email$/ })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('validates required fields in mapping', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n';
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-required.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
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();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('navigates back from mapping to upload', async ({ page }) => {
|
||||
const csvContent = 'Nom;Prénom;Email\nDupont;Jean;jean@ecole.fr\n';
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-back.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
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 });
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('rejects non-CSV files', async ({ page }) => {
|
||||
const txtPath = createCsvFixture('e2e-teacher-import-bad.pdf', 'not a csv file');
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
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 });
|
||||
|
||||
try { unlinkSync(txtPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('shows preview step with valid/error counts', async ({ page }) => {
|
||||
const csvContent =
|
||||
'Nom;Prénom;Email\nDupont;Jean;jean.preview@ecole.fr\n;Marie;marie@ecole.fr\nMartin;;martin@ecole.fr\n';
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-preview.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
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();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P0] completes full import flow with progress and report', async ({ page }) => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const email1 = `alice.prof.${suffix}@ecole.fr`;
|
||||
const email2 = `bob.prof.${suffix}@ecole.fr`;
|
||||
const csvContent = `Nom;Prénom;Email\nTestProf${suffix};Alice;${email1}\nTestProf${suffix};Bob;${email2}\n`;
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-full-flow.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
// 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
|
||||
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: /voir les utilisateurs/i })).toBeVisible();
|
||||
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}'))`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${email1}', '${email2}')`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] imports only valid rows when errors exist', async ({ page }) => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const validEmail = `sophie.durand.${suffix}@ecole.fr`;
|
||||
const csvContent = `Nom;Prénom;Email\nDurand${suffix};Sophie;${validEmail}\n;Marie;marie.err@ecole.fr\nMartin;;martin.err@ecole.fr\n`;
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-valid-only.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
// 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 teacher 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
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}')`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${validEmail}'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P1] shows unknown subjects and allows auto-creation', async ({ page }) => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const subjectName = `E2E AutoSubject${suffix}`;
|
||||
const email = `paul.lemaire.${suffix}@ecole.fr`;
|
||||
const csvContent = `Nom;Prénom;Email;Matières\nLemaire${suffix};Paul;${email};${subjectName}\n`;
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-auto-subject.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
// 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 subjects section
|
||||
await expect(page.locator('.unknown-items').first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.item-tag').first()).toContainText(subjectName);
|
||||
|
||||
// Check auto-create checkbox
|
||||
await page.locator('.unknown-items input[type="checkbox"]').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 teacher 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
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}')`);
|
||||
runCommand(`DELETE FROM subjects WHERE tenant_id = '${TENANT_ID}' AND name = '${subjectName}'`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${email}'`);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
test('[P2] shows preview of first 5 lines in mapping step', async ({ page }) => {
|
||||
const csvContent = [
|
||||
'Nom;Prénom;Email',
|
||||
'Alpha;Un;alpha.un@ecole.fr',
|
||||
'Bravo;Deux;bravo.deux@ecole.fr',
|
||||
'Charlie;Trois;charlie.trois@ecole.fr',
|
||||
'Delta;Quatre;delta.quatre@ecole.fr',
|
||||
'Echo;Cinq;echo.cinq@ecole.fr',
|
||||
'Foxtrot;Six;foxtrot.six@ecole.fr',
|
||||
'Golf;Sept;golf.sept@ecole.fr',
|
||||
'Hotel;Huit;hotel.huit@ecole.fr'
|
||||
].join('\n') + '\n';
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-preview-5.csv', csvContent);
|
||||
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
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('[P1] skips teachers with duplicate emails and shows duplicate card', async ({ page }) => {
|
||||
const DUPLICATE_EMAIL = 'e2e-duplicate-teacher@ecole.fr';
|
||||
const UNIQUE_EMAIL = `e2e-unique-teacher-${Date.now()}@ecole.fr`;
|
||||
|
||||
// Clean up any stale user from previous runs (DB + cache) to avoid cache/DB desync
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}')`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email = '${DUPLICATE_EMAIL}'`);
|
||||
} catch { /* ignore */ }
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create a pre-existing user with the duplicate email
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${DUPLICATE_EMAIL} --password=Unused123 --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const csvContent = `Nom;Prénom;Email\nExistant;Dupli;${DUPLICATE_EMAIL}\nNouveau;Unique;${UNIQUE_EMAIL}\n`;
|
||||
const csvPath = createCsvFixture('e2e-teacher-import-duplicate-email.csv', csvContent);
|
||||
|
||||
try {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/import/teachers`);
|
||||
|
||||
// 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 — duplicates are detected and shown in the summary
|
||||
await expect(page.locator('.preview-summary')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Verify duplicate card is visible
|
||||
await expect(page.locator('.summary-card.duplicate')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.summary-card.duplicate .summary-number')).toHaveText('1');
|
||||
|
||||
// "Ignorer les doublons" radio should be default
|
||||
const ignoreRadio = page.locator('input[type="radio"][name="duplicateMode"][value="false"]');
|
||||
await expect(ignoreRadio).toBeChecked();
|
||||
|
||||
// Launch import with "ignore" mode (default)
|
||||
await page.getByRole('button', { name: /lancer l'import/i }).click();
|
||||
|
||||
// Step 4: Report — wait for completion
|
||||
await expect(page.getByRole('heading', { name: /import terminé/i })).toBeVisible({ timeout: 60000 });
|
||||
|
||||
// Verify report: 1 imported (unique email), 0 errors (duplicate was filtered by "import valid only")
|
||||
const stats = page.locator('.report-stats .stat');
|
||||
const importedStat = stats.filter({ hasText: /importés/ });
|
||||
await expect(importedStat.locator('.stat-value')).toHaveText('1');
|
||||
const errorStat = stats.filter({ hasText: /erreurs/ });
|
||||
await expect(errorStat.locator('.stat-value')).toHaveText('0');
|
||||
} finally {
|
||||
try { unlinkSync(csvPath); } catch { /* ignore */ }
|
||||
|
||||
// Cleanup: remove both the pre-existing and imported users (DB + cache)
|
||||
try {
|
||||
runCommand(`DELETE FROM teacher_assignments WHERE teacher_id IN (SELECT id FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}'))`);
|
||||
runCommand(`DELETE FROM users WHERE tenant_id = '${TENANT_ID}' AND email IN ('${DUPLICATE_EMAIL}', '${UNIQUE_EMAIL}')`);
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear users.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -81,11 +81,16 @@
|
||||
<span class="action-label">Identité visuelle</span>
|
||||
<span class="action-hint">Logo et couleurs</span>
|
||||
</a>
|
||||
<div class="action-card disabled" aria-disabled="true">
|
||||
<a class="action-card" href="/admin/import/students">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-label">Importer des données</span>
|
||||
<span class="action-hint">Bientôt disponible</span>
|
||||
</div>
|
||||
<span class="action-label">Importer des élèves</span>
|
||||
<span class="action-hint">CSV ou XLSX</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/import/teachers">
|
||||
<span class="action-icon">📤</span>
|
||||
<span class="action-label">Importer des enseignants</span>
|
||||
<span class="action-hint">CSV ou XLSX</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,18 +203,13 @@
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-card:not(.disabled):hover {
|
||||
.action-card:hover {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-card.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
187
frontend/src/lib/features/import/api/teacherImport.ts
Normal file
187
frontend/src/lib/features/import/api/teacherImport.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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[];
|
||||
unknownSubjects: string[];
|
||||
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'enseignants.
|
||||
*/
|
||||
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/teachers/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/teachers/${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/teachers/${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: { createMissingSubjects: boolean; importValidOnly: boolean; updateExisting: boolean }
|
||||
): Promise<ConfirmResult> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/import/teachers/${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/teachers/${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/teachers/${batchId}/report`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération du rapport');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
@@ -69,6 +69,22 @@
|
||||
let createMissingClasses = $state(false);
|
||||
let importValidOnly = $state(true);
|
||||
|
||||
// Adjusted preview rows: when createMissingClasses is checked, treat class-not-found errors as resolved
|
||||
let adjustedPreviewRows = $derived.by(() => {
|
||||
if (!previewResult) return [];
|
||||
if (!createMissingClasses) return previewResult.rows;
|
||||
return previewResult.rows.map((row) => {
|
||||
if (row.valid) return row;
|
||||
const remainingErrors = row.errors.filter((e) => e.column !== 'className');
|
||||
return { ...row, valid: remainingErrors.length === 0, errors: remainingErrors };
|
||||
});
|
||||
});
|
||||
let adjustedValidCount = $derived(adjustedPreviewRows.filter((r) => r.valid).length);
|
||||
let adjustedErrorCount = $derived(adjustedPreviewRows.filter((r) => !r.valid).length);
|
||||
let duplicateCount = $derived(
|
||||
adjustedPreviewRows.filter((r) => r.errors.some((e) => e.column === '_duplicate')).length
|
||||
);
|
||||
|
||||
// === Step 4: Confirmation ===
|
||||
let isConfirming = $state(false);
|
||||
let importStatus = $state<ImportStatus | null>(null);
|
||||
@@ -395,6 +411,7 @@
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Zone de dépôt de fichier"
|
||||
@@ -596,13 +613,19 @@
|
||||
<!-- Summary -->
|
||||
<div class="preview-summary">
|
||||
<div class="summary-card valid">
|
||||
<span class="summary-number">{previewResult.validCount}</span>
|
||||
<span class="summary-number">{adjustedValidCount}</span>
|
||||
<span class="summary-label">Lignes valides</span>
|
||||
</div>
|
||||
<div class="summary-card error">
|
||||
<span class="summary-number">{previewResult.errorCount}</span>
|
||||
<span class="summary-number">{adjustedErrorCount}</span>
|
||||
<span class="summary-label">Lignes en erreur</span>
|
||||
</div>
|
||||
{#if duplicateCount > 0}
|
||||
<div class="summary-card duplicate">
|
||||
<span class="summary-number">{duplicateCount}</span>
|
||||
<span class="summary-label">Doublons détectés</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="summary-card total">
|
||||
<span class="summary-number">{previewResult.totalRows}</span>
|
||||
<span class="summary-label">Total</span>
|
||||
@@ -611,9 +634,14 @@
|
||||
|
||||
<!-- Unknown classes -->
|
||||
{#if previewResult.unknownClasses.length > 0}
|
||||
<div class="unknown-classes">
|
||||
<h3>Classes non trouvées</h3>
|
||||
<p>Les classes suivantes n'existent pas encore dans Classeo :</p>
|
||||
<div class="unknown-classes" class:resolved={createMissingClasses}>
|
||||
{#if createMissingClasses}
|
||||
<h3>Classes qui seront créées</h3>
|
||||
<p>Les classes suivantes seront créées automatiquement lors de l'import :</p>
|
||||
{:else}
|
||||
<h3>Classes non trouvées</h3>
|
||||
<p>Les classes suivantes n'existent pas encore dans Classeo :</p>
|
||||
{/if}
|
||||
<div class="class-tags">
|
||||
{#each previewResult.unknownClasses as cls}
|
||||
<span class="class-tag">{cls}</span>
|
||||
@@ -627,11 +655,11 @@
|
||||
{/if}
|
||||
|
||||
<!-- Import options -->
|
||||
{#if previewResult.errorCount > 0}
|
||||
{#if adjustedErrorCount > 0}
|
||||
<div class="import-options">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="importMode" value={true} bind:group={importValidOnly} />
|
||||
Importer uniquement les {previewResult.validCount} lignes valides
|
||||
Importer uniquement les {adjustedValidCount} lignes valides
|
||||
</label>
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="importMode" value={false} bind:group={importValidOnly} />
|
||||
@@ -656,7 +684,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each previewResult.rows as row}
|
||||
{#each adjustedPreviewRows as row}
|
||||
<tr class:row-valid={row.valid} class:row-error={!row.valid}>
|
||||
<td>{row.line}</td>
|
||||
<td>{row.data['lastName'] ?? ''}</td>
|
||||
@@ -698,12 +726,12 @@
|
||||
<button
|
||||
class="btn-primary"
|
||||
onclick={launchImport}
|
||||
disabled={isConfirming || (previewResult.validCount === 0 && importValidOnly)}
|
||||
disabled={isConfirming || (adjustedValidCount === 0 && importValidOnly)}
|
||||
>
|
||||
{#if isConfirming}
|
||||
Lancement...
|
||||
{:else}
|
||||
Lancer l'import ({importValidOnly ? previewResult.validCount : previewResult.totalRows} élèves)
|
||||
Lancer l'import ({importValidOnly ? adjustedValidCount : previewResult.totalRows} élèves)
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1316,6 +1344,11 @@
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.summary-card.duplicate {
|
||||
background: #fffbeb;
|
||||
border-color: #fde68a;
|
||||
}
|
||||
|
||||
.summary-card.total {
|
||||
background: #f9fafb;
|
||||
}
|
||||
@@ -1334,6 +1367,10 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.summary-card.duplicate .summary-number {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
@@ -1390,6 +1427,11 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.unknown-classes.resolved {
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.unknown-classes h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
@@ -1397,12 +1439,20 @@
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.unknown-classes.resolved h3 {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.unknown-classes p {
|
||||
font-size: 0.8125rem;
|
||||
color: #92400e;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.unknown-classes.resolved p {
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.class-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
1729
frontend/src/routes/admin/import/teachers/+page.svelte
Normal file
1729
frontend/src/routes/admin/import/teachers/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -515,10 +515,15 @@
|
||||
<h1>Gestion des utilisateurs</h1>
|
||||
<p class="subtitle">Invitez et gérez les utilisateurs de votre établissement</p>
|
||||
</div>
|
||||
<button class="btn-primary" onclick={openCreateModal}>
|
||||
<span class="btn-icon">+</span>
|
||||
Inviter un utilisateur
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<a href="/admin/import/teachers" class="btn-secondary">
|
||||
Importer enseignants (CSV)
|
||||
</a>
|
||||
<button class="btn-primary" onclick={openCreateModal}>
|
||||
<span class="btn-icon">+</span>
|
||||
Inviter un utilisateur
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
@@ -942,6 +947,13 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
|
||||
Reference in New Issue
Block a user