feat: Provisionner automatiquement un nouvel établissement
Lorsqu'un super-admin crée un établissement via l'interface, le système doit automatiquement créer la base tenant, exécuter les migrations, créer le premier utilisateur admin et envoyer l'invitation — le tout de manière asynchrone pour ne pas bloquer la réponse HTTP. Ce mécanisme rend chaque établissement opérationnel dès sa création sans intervention manuelle sur l'infrastructure.
This commit is contained in:
@@ -171,7 +171,7 @@ test.describe('Grade Input Grid (Story 6.2)', () => {
|
||||
await firstInput.fill('25');
|
||||
|
||||
// Should show error
|
||||
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.input-error-msg').first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
// Réutilise le même enseignant que homework.spec.ts pour partager le setup
|
||||
const TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
@@ -105,7 +104,6 @@ async function createHomework(page: import('@playwright/test').Page, title: stri
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill(title);
|
||||
|
||||
// Type in WYSIWYG editor (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
@@ -158,9 +156,8 @@ function cleanupTempFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
test.describe('Homework Attachments (Story 5.9/5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Ensure teacher user exists (same as homework.spec.ts)
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
@@ -189,7 +186,6 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// homework_submissions has NO CASCADE on homework_id — delete submissions first
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
@@ -198,138 +194,41 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Disable any homework rules left by other test files (homework-rules-warning,
|
||||
// homework-rules-hard) to prevent rule warnings blocking homework creation.
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
|
||||
// Clear school calendar entries that may block dates (Vacances de Printemps, etc.)
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.1 : WYSIWYG Editor
|
||||
// ============================================================================
|
||||
test.describe('WYSIWYG Editor', () => {
|
||||
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Rich text editor with toolbar should be visible
|
||||
const editor = page.locator('.rich-text-editor');
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.toolbar')).toBeVisible();
|
||||
|
||||
// Toolbar buttons
|
||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create homework with rich text description', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir texte riche');
|
||||
|
||||
// Type in rich text editor (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes importantes');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('bold formatting works in editor', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir gras test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
// Apply bold via keyboard shortcut (more reliable than toolbar click)
|
||||
await page.keyboard.press('Control+b');
|
||||
await page.keyboard.type('en gras');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify bold is rendered in the description
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('strong')).toContainText('en gras');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.2 : Upload attachment
|
||||
// ============================================================================
|
||||
test.describe('Attachments', () => {
|
||||
test.describe('Upload & Delete', () => {
|
||||
test('can upload a PDF attachment to homework via edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Create homework
|
||||
await createHomework(page, 'Devoir avec PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir avec PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload file
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
// File appears in list
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
// T4.3 : Delete attachment
|
||||
test('can delete an uploaded attachment', async ({ page }) => {
|
||||
test.slow(); // upload + delete needs more than 30s
|
||||
test.slow();
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir suppr PJ');
|
||||
|
||||
// Open edit modal and upload
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir suppr PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
@@ -338,80 +237,142 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
await page.locator('.file-input-hidden').setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Delete the attachment
|
||||
await page.getByRole('button', { name: /supprimer test-attachment.pdf/i }).click();
|
||||
await expect(page.getByText('test-attachment.pdf')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.1 : Invalid file type rejection (P1)
|
||||
// ============================================================================
|
||||
test.describe('Invalid File Type Rejection', () => {
|
||||
test.describe('Drop Zone UI', () => {
|
||||
test('create form shows drag-and-drop zone for attachments', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir drop zone');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drop zone' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const dropZone = page.locator('.drop-zone');
|
||||
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||
await expect(dropZone).toContainText('Glissez-déposez');
|
||||
await expect(dropZone.locator('.drop-zone-browse')).toContainText('parcourir');
|
||||
});
|
||||
|
||||
test('browse button in drop zone opens file dialog and uploads', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir browse btn');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir browse btn' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('drag-and-drop visual feedback appears on dragover', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir drag feedback');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir drag feedback' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const dropZone = page.locator('.drop-zone');
|
||||
await expect(dropZone).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await dropZone.evaluate((el) => {
|
||||
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: new DataTransfer(), bubbles: true }));
|
||||
});
|
||||
await expect(dropZone).toHaveClass(/drop-zone-active/);
|
||||
|
||||
await dropZone.evaluate((el) => {
|
||||
el.dispatchEvent(new DragEvent('dragleave', { bubbles: true }));
|
||||
});
|
||||
await expect(dropZone).not.toHaveClass(/drop-zone-active/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('File Type Badge', () => {
|
||||
test('uploaded PDF shows formatted type badge', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir badge type');
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir badge type' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const fileType = page.locator('.file-type');
|
||||
await expect(fileType).toBeVisible({ timeout: 5000 });
|
||||
await expect(fileType).toHaveText('PDF');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Validation', () => {
|
||||
test('rejects a .txt file with an error message', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir rejet fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir rejet fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Try to upload a .txt file
|
||||
const txtPath = createTempTxt();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(txtPath);
|
||||
|
||||
// Error message should appear
|
||||
const errorAlert = page.locator('[role="alert"]');
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5000 });
|
||||
await expect(errorAlert).toContainText('Type de fichier non accepté');
|
||||
|
||||
// The .txt file should NOT appear in the file list
|
||||
await expect(page.getByText('test-invalid.txt')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.2 : Attachment persistence after save (P1)
|
||||
// ============================================================================
|
||||
test.describe('Attachment Persistence', () => {
|
||||
test.describe('Persistence', () => {
|
||||
test('uploaded attachment persists after saving and reopening edit modal', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await createHomework(page, 'Devoir persistance PJ');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Save the changes
|
||||
await page.getByRole('button', { name: /enregistrer/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Reopen the edit modal
|
||||
const hwCardAfterSave = page.locator('.homework-card', { hasText: 'Devoir persistance PJ' });
|
||||
await hwCardAfterSave.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// The attachment should still be there
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T5.9.3 : File size display after upload (P2)
|
||||
// ============================================================================
|
||||
test.describe('File Size Display', () => {
|
||||
test('shows formatted file size after uploading a PDF', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
@@ -419,73 +380,18 @@ test.describe('Rich Text & Attachments (Story 5.9)', () => {
|
||||
|
||||
await createHomework(page, 'Devoir taille fichier');
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir taille fichier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Upload a PDF
|
||||
const pdfPath = createTempPdf();
|
||||
const fileInput = page.locator('.file-input-hidden');
|
||||
await fileInput.setInputFiles(pdfPath);
|
||||
await expect(page.getByText('test-attachment.pdf')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// The file size element should be visible and show a formatted size (e.g., "xxx o" or "xxx Ko")
|
||||
const fileSize = page.locator('.file-size');
|
||||
await expect(fileSize).toBeVisible({ timeout: 5000 });
|
||||
await expect(fileSize).toHaveText(/\d+(\.\d+)?\s*(o|Ko|Mo)/);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// T4.4 : Backward compatibility
|
||||
// ============================================================================
|
||||
test.describe('Backward Compatibility', () => {
|
||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||
// Create homework with plain text description via SQL
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Plain text description displays correctly
|
||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// WYSIWYG editor contains the old text (TipTap initializes asynchronously)
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
395
frontend/e2e/homework-calendar.spec.ts
Normal file
395
frontend/e2e/homework-calendar.spec.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
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: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
const day = date.getDay();
|
||||
if (day === 0) date.setDate(date.getDate() + 1);
|
||||
if (day === 6) date.setDate(date.getDate() + 2);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function seedTeacherAssignments() {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('Calendar Date Picker (Story 5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test('create form shows calendar date picker instead of native input', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Calendar date picker should be visible
|
||||
const picker = page.locator('.calendar-date-picker');
|
||||
await expect(picker).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Trigger button should show placeholder text
|
||||
await expect(picker.locator('.picker-trigger')).toContainText('Choisir une date');
|
||||
|
||||
// Native date input is sr-only (hidden but present for form semantics)
|
||||
await expect(page.locator('#hw-due-date')).toHaveAttribute('aria-hidden', 'true');
|
||||
});
|
||||
|
||||
test('clicking picker opens calendar dropdown', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
|
||||
// Calendar dropdown should be visible
|
||||
const dropdown = picker.locator('.calendar-dropdown');
|
||||
await expect(dropdown).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Day names header should be visible
|
||||
await expect(dropdown.locator('.day-name').first()).toBeVisible();
|
||||
|
||||
// Month label should be visible
|
||||
await expect(dropdown.locator('.month-label')).toBeVisible();
|
||||
});
|
||||
|
||||
test('weekends are disabled in calendar', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Weekend cells should have the weekend class and be disabled
|
||||
const weekendCells = picker.locator('.day-cell.weekend');
|
||||
const count = await weekendCells.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Weekend cells should be disabled
|
||||
const firstWeekend = weekendCells.first();
|
||||
await expect(firstWeekend).toBeDisabled();
|
||||
});
|
||||
|
||||
test('can select a date by clicking a day in the calendar', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir calendrier test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Test description');
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Find a valid weekday button (not disabled, not weekend, not before-min)
|
||||
const validDays = picker.locator('.day-cell:not(.weekend):not(.blocked):not(.before-min):not(.empty):enabled');
|
||||
const validCount = await validDays.count();
|
||||
expect(validCount).toBeGreaterThan(0);
|
||||
|
||||
// Click the last available day (likely to be far enough in the future)
|
||||
await validDays.last().click();
|
||||
|
||||
// Dropdown should close
|
||||
await expect(picker.locator('.calendar-dropdown')).not.toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Trigger should now show the selected date (not placeholder)
|
||||
await expect(picker.locator('.picker-value')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can navigate to next/previous month', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
const monthLabel = picker.locator('.month-label');
|
||||
const initialMonth = await monthLabel.textContent();
|
||||
|
||||
// Navigate to next month
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
const nextMonth = await monthLabel.textContent();
|
||||
expect(nextMonth).not.toBe(initialMonth);
|
||||
|
||||
// Navigate back
|
||||
await picker.getByRole('button', { name: /mois précédent/i }).click();
|
||||
const backMonth = await monthLabel.textContent();
|
||||
expect(backMonth).toBe(initialMonth);
|
||||
});
|
||||
|
||||
test('can create homework using calendar date picker (hidden input fallback)', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir via calendrier');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Description du devoir');
|
||||
|
||||
// Use hidden date input for programmatic date selection (E2E compatibility)
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir via calendrier')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('edit modal shows calendar date picker with current date selected', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
// Create homework first via hidden input
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir edit calendrier');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Description');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open edit modal
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit calendrier' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Edit modal should have calendar picker with date displayed
|
||||
const editPicker = page.locator('.modal .calendar-date-picker');
|
||||
await expect(editPicker).toBeVisible({ timeout: 5000 });
|
||||
await expect(editPicker.locator('.picker-value')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rule-hard blocked dates show colored dots in calendar', async ({ page }) => {
|
||||
// Seed a homework rule (minimum_delay=30 days, hard mode) so dates within 30 days are blocked.
|
||||
// Using 30 days ensures the current month always has blocked weekdays visible.
|
||||
const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":30}}]';
|
||||
runSql(
|
||||
`INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` +
|
||||
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Navigate to next month to guarantee blocked dates are visible
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
|
||||
// Days within the 30-day delay window should be rule-blocked
|
||||
const blockedWithDot = picker.locator('.day-cell.blocked .blocked-dot');
|
||||
await expect(blockedWithDot.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Legend should show "Règle (bloquant)"
|
||||
await expect(picker.locator('.calendar-legend')).toContainText('Règle (bloquant)');
|
||||
});
|
||||
|
||||
test('blocked dates (holidays) show colored dots in calendar', async ({ page }) => {
|
||||
// Seed a holiday entry covering a guaranteed weekday next month
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
// Start from the 10th and find the first weekday (Mon-Fri)
|
||||
let holidayDay = 10;
|
||||
const probe = new Date(nextMonth.getFullYear(), nextMonth.getMonth(), holidayDay);
|
||||
while (probe.getDay() === 0 || probe.getDay() === 6) {
|
||||
holidayDay++;
|
||||
probe.setDate(holidayDay);
|
||||
}
|
||||
const holidayDate = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth() + 1).padStart(2, '0')}-${String(holidayDay).padStart(2, '0')}`;
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_calendar_entries (id, tenant_id, academic_year_id, entry_type, start_date, end_date, label, description, created_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${academicYearId}', 'holiday', '${holidayDate}', '${holidayDate}', 'Jour férié E2E', NULL, NOW())`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const picker = page.locator('.modal .calendar-date-picker');
|
||||
await picker.locator('.picker-trigger').click();
|
||||
await expect(picker.locator('.calendar-dropdown')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Navigate to next month
|
||||
await picker.getByRole('button', { name: /mois suivant/i }).click();
|
||||
|
||||
// The holiday day should be blocked with a colored dot
|
||||
const holidayCell = picker.getByRole('gridcell', { name: String(holidayDay), exact: true });
|
||||
await expect(holidayCell).toBeVisible({ timeout: 5000 });
|
||||
await expect(holidayCell).toBeDisabled();
|
||||
await expect(holidayCell.locator('.blocked-dot')).toBeVisible();
|
||||
|
||||
// Legend should show "Jour férié"
|
||||
await expect(picker.locator('.calendar-legend')).toContainText('Jour férié');
|
||||
});
|
||||
});
|
||||
@@ -297,8 +297,8 @@ test.describe('Homework Exception Request (Story 5.6)', () => {
|
||||
.fill('Justification suffisamment longue pour être valide.');
|
||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||
|
||||
// Wait for homework to appear
|
||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
|
||||
// Wait for homework to appear (Firefox needs more time after exception flow)
|
||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Exception badge visible (⚠ Exception text or rule override badge)
|
||||
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
||||
|
||||
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
316
frontend/e2e/homework-wysiwyg.spec.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
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 TEACHER_EMAIL = 'e2e-homework-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'HomeworkTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
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: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
const day = date.getDay();
|
||||
if (day === 0) date.setDate(date.getDate() + 1);
|
||||
if (day === 6) date.setDate(date.getDate() + 2);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function seedTeacherAssignments() {
|
||||
const { academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
}
|
||||
|
||||
async function loginAsTeacher(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(TEACHER_EMAIL);
|
||||
await page.locator('#password').fill(TEACHER_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToHomework(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`);
|
||||
await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
test.describe('WYSIWYG Editor & Backward Compatibility (Story 5.9/5.11)', () => {
|
||||
test.beforeAll(async () => {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-HW-Maths', 'E2EMAT', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runSql(`DELETE FROM submission_attachments WHERE submission_id IN (SELECT hs.id FROM homework_submissions hs JOIN homework h ON hs.homework_id = h.id WHERE h.tenant_id = '${TENANT_ID}' AND h.teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework_submissions WHERE homework_id IN (SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'))`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`UPDATE homework_rules SET enabled = false, updated_at = NOW() WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch { /* Table may not exist */ }
|
||||
clearCache();
|
||||
});
|
||||
|
||||
test.describe('WYSIWYG Editor', () => {
|
||||
test('create form shows rich text editor with toolbar', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const editor = page.locator('.rich-text-editor');
|
||||
await expect(editor).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('.toolbar')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Gras' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Italique' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste à puces' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Liste numérotée' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Lien' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can create homework with rich text description', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir texte riche');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Consignes importantes');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir texte riche')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('bold formatting works in editor', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir gras test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
await page.keyboard.press('Control+b');
|
||||
await page.keyboard.type('en gras');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir gras test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('strong')).toContainText('en gras');
|
||||
});
|
||||
|
||||
test('italic formatting works in editor', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir italique test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
await page.keyboard.type('Normal ');
|
||||
|
||||
await page.keyboard.press('Control+i');
|
||||
await page.keyboard.type('en italique');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir italique test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('em')).toContainText('en italique');
|
||||
});
|
||||
|
||||
test('bullet list formatting works in editor', async ({ page }) => {
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await page.getByRole('button', { name: /nouveau devoir/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.locator('#hw-class').selectOption({ index: 1 });
|
||||
await page.locator('#hw-subject').selectOption({ index: 1 });
|
||||
await page.locator('#hw-title').fill('Devoir liste test');
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await editorContent.click();
|
||||
|
||||
await editorContent.press('Control+Shift+8');
|
||||
await page.keyboard.type('Premier élément');
|
||||
|
||||
await page.locator('#hw-due-date').fill(getNextWeekday(5));
|
||||
await page.getByRole('button', { name: /créer le devoir/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Devoir liste test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const description = page.locator('.homework-description');
|
||||
await expect(description.locator('ul')).toBeVisible();
|
||||
await expect(description.locator('li')).toContainText('Premier élément');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Backward Compatibility', () => {
|
||||
test('existing plain text homework displays correctly', async ({ page }) => {
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir texte brut E2E', 'Description simple sans balise HTML', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
await expect(page.getByText('Devoir texte brut E2E')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Description simple sans balise HTML')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit modal loads plain text in WYSIWYG editor', async ({ page }) => {
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, u.id, 'Devoir edit brut E2E', 'Ancienne description', '${getNextWeekday(10)}', 'published', NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c, subjects s ` +
|
||||
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.tenant_id = '${TENANT_ID}' AND c.name = 'E2E-HW-6A' ` +
|
||||
`AND s.tenant_id = '${TENANT_ID}' AND s.name = 'E2E-HW-Maths' ` +
|
||||
`LIMIT 1`
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
|
||||
const hwCard = page.locator('.homework-card', { hasText: 'Devoir edit brut E2E' });
|
||||
await hwCard.getByRole('button', { name: /modifier/i }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const editorContent = page.locator('.modal .rich-text-content');
|
||||
await expect(editorContent).toBeVisible({ timeout: 10000 });
|
||||
await expect(editorContent).toContainText('Ancienne description', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
password: string
|
||||
) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await Promise.all([
|
||||
|
||||
167
frontend/e2e/role-assignment.spec.ts
Normal file
167
frontend/e2e/role-assignment.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
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-roles-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'RolesAdmin123';
|
||||
const TARGET_EMAIL = `e2e-roles-target-${Date.now()}@example.com`;
|
||||
const TARGET_PASSWORD = 'RolesTarget123';
|
||||
|
||||
test.describe('Multi-Role Assignment (FR5) [P2]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
// 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' }
|
||||
);
|
||||
|
||||
// Create target user with single role (PROF)
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TARGET_EMAIL} --password=${TARGET_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
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: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function openRolesModalForTarget(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await expect(page.locator('.users-table')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Search for the target user (paginated list may not show them on page 1)
|
||||
await page.getByRole('searchbox').fill(TARGET_EMAIL);
|
||||
await page.waitForTimeout(500); // debounce
|
||||
|
||||
// Find the target user row and click "Rôles" button
|
||||
const targetRow = page.locator('tr', { has: page.locator(`text=${TARGET_EMAIL}`) });
|
||||
await expect(targetRow).toBeVisible({ timeout: 10000 });
|
||||
await targetRow.getByRole('button', { name: 'Rôles' }).click();
|
||||
|
||||
// Modal should appear
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.locator('#roles-modal-title')).toHaveText('Modifier les rôles');
|
||||
}
|
||||
|
||||
test('[P2] admin can open role modal showing current roles', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Target user email should be displayed in modal
|
||||
await expect(page.locator('.roles-modal-user')).toContainText(TARGET_EMAIL);
|
||||
|
||||
// ROLE_PROF should be checked (current role)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Other roles should be unchecked
|
||||
const adminCheckbox = page.locator('.role-checkbox-label', { hasText: 'Directeur' }).locator('input[type="checkbox"]');
|
||||
await expect(adminCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can assign multiple roles to a user', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Add Vie Scolaire role in addition to PROF
|
||||
const vieScolaireLabel = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' });
|
||||
await vieScolaireLabel.locator('input[type="checkbox"]').check();
|
||||
|
||||
// Both should now be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireLabel.locator('input[type="checkbox"]')).toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
const saveResponse = await saveResponsePromise;
|
||||
expect(saveResponse.status()).toBeLessThan(400);
|
||||
|
||||
// Success message should appear
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] assigned roles persist after page reload', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Both PROF and VIE_SCOLAIRE should still be checked after reload
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('[P2] admin can remove a role while keeping at least one', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Uncheck Vie Scolaire (added in previous test)
|
||||
const vieScolaireCheckbox = page.locator('.role-checkbox-label', { hasText: 'Vie Scolaire' }).locator('input[type="checkbox"]');
|
||||
await vieScolaireCheckbox.uncheck();
|
||||
|
||||
// PROF should still be checked
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
await expect(vieScolaireCheckbox).not.toBeChecked();
|
||||
|
||||
// Save
|
||||
const saveResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/roles') && resp.request().method() === 'PUT'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
await saveResponsePromise;
|
||||
|
||||
await expect(page.locator('.alert-success')).toContainText(/rôles.*mis à jour/i, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('[P2] last role checkbox is disabled to prevent removal', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
// Only PROF should be checked now (after previous test removed VIE_SCOLAIRE)
|
||||
const profCheckbox = page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('input[type="checkbox"]');
|
||||
await expect(profCheckbox).toBeChecked();
|
||||
|
||||
// Last role checkbox should be disabled
|
||||
await expect(profCheckbox).toBeDisabled();
|
||||
|
||||
// "(dernier rôle)" hint should be visible
|
||||
await expect(
|
||||
page.locator('.role-checkbox-label', { hasText: 'Enseignant' }).locator('.role-checkbox-hint')
|
||||
).toContainText('dernier rôle');
|
||||
});
|
||||
|
||||
test('[P2] role modal can be closed with Escape', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await openRolesModalForTarget(page);
|
||||
|
||||
await page.getByRole('dialog').press('Escape');
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -264,14 +264,15 @@ test.describe('Sessions Management', () => {
|
||||
|
||||
await login(page, email);
|
||||
await page.goto(getTenantUrl('/settings'));
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Click logout button
|
||||
const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for redirect to login
|
||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
||||
await Promise.all([
|
||||
page.waitForURL(/login/, { timeout: 30000 }),
|
||||
logoutButton.click()
|
||||
]);
|
||||
});
|
||||
|
||||
test('logout clears authentication', async ({ page, browserName }, testInfo) => {
|
||||
@@ -288,8 +289,8 @@ test.describe('Sessions Management', () => {
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await logoutButton.click();
|
||||
|
||||
// Wait for redirect to login
|
||||
await expect(page).toHaveURL(/login/, { timeout: 10000 });
|
||||
// Wait for redirect to login (Firefox can be slow to redirect)
|
||||
await expect(page).toHaveURL(/login/, { timeout: 20000 });
|
||||
|
||||
// Try to access protected page
|
||||
await page.goto(getTenantUrl('/settings/sessions'));
|
||||
|
||||
@@ -153,10 +153,11 @@ test.describe('Settings Page [P1]', () => {
|
||||
await login(page, email);
|
||||
|
||||
await page.goto(getTenantUrl('/settings'));
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: /tableau de bord/i })
|
||||
).toBeVisible();
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] settings layout shows Parametres navigation link as active', async ({ page }, testInfo) => {
|
||||
|
||||
@@ -484,7 +484,7 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
|
||||
// Should find the student (use .first() because AC3 duplicate test creates a second one)
|
||||
await expect(
|
||||
page.locator('td', { hasText: `Dupont-${UNIQUE_SUFFIX}` }).first()
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
|
||||
test('rows are clickable and navigate to student detail', async ({ page }) => {
|
||||
|
||||
@@ -481,7 +481,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await expect(firstSlot).toBeVisible({ timeout: 20000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
205
frontend/e2e/super-admin-provisioning.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
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 SA_PASSWORD = 'SuperAdmin123';
|
||||
const UNIQUE_SUFFIX = Date.now();
|
||||
|
||||
function getSuperAdminEmail(browserName: string): string {
|
||||
return `e2e-prov-sa-${browserName}@test.com`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
test.beforeAll(async ({}, testInfo) => {
|
||||
const browserName = testInfo.project.name;
|
||||
const saEmail = getSuperAdminEmail(browserName);
|
||||
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-super-admin --email=${saEmail} --password=${SA_PASSWORD} 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`[${browserName}] Failed to create super admin:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
async function loginAsSuperAdmin(
|
||||
page: import('@playwright/test').Page,
|
||||
email: string
|
||||
) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await expect(page.getByRole('heading', { name: /connexion/i })).toBeVisible();
|
||||
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(SA_PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /se connecter/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/dashboard', { timeout: 30000 }),
|
||||
submitButton.click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function navigateToEstablishments(page: import('@playwright/test').Page) {
|
||||
const link = page.getByRole('link', { name: /établissements/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
link.click()
|
||||
]);
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
async function navigateToCreateForm(page: import('@playwright/test').Page) {
|
||||
const newLink = page.getByRole('link', { name: /nouvel établissement/i });
|
||||
await expect(newLink).toBeVisible({ timeout: 10000 });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments/new', { timeout: 10000 }),
|
||||
newLink.click()
|
||||
]);
|
||||
await expect(page.locator('#name')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Establishment Provisioning (Story 2-17) [P1]', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Subdomain Auto-generation', () => {
|
||||
test('[P1] typing name auto-generates subdomain', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('École Saint-Exupéry');
|
||||
|
||||
// Subdomain should be auto-generated: accents removed, spaces→hyphens, lowercase
|
||||
await expect(page.locator('#subdomain')).toHaveValue('ecole-saint-exupery');
|
||||
});
|
||||
|
||||
test('[P2] subdomain suffix .classeo.fr is displayed', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await expect(page.locator('.subdomain-suffix')).toHaveText('.classeo.fr');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Create Establishment Flow', () => {
|
||||
const establishmentName = `E2E Test ${UNIQUE_SUFFIX}`;
|
||||
const adminEmailForEstab = `admin-prov-${UNIQUE_SUFFIX}@test.com`;
|
||||
|
||||
test('[P1] submitting form creates establishment and redirects to list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
// Fill in the form
|
||||
await page.locator('#name').fill(establishmentName);
|
||||
await page.locator('#adminEmail').fill(adminEmailForEstab);
|
||||
|
||||
// Subdomain should be auto-generated
|
||||
const subdomain = await page.locator('#subdomain').inputValue();
|
||||
expect(subdomain.length).toBeGreaterThan(0);
|
||||
|
||||
// Submit
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
const apiResponsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes('/super-admin/establishments') && resp.request().method() === 'POST'
|
||||
);
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
// Verify API returns establishment in provisioning status
|
||||
const apiResponse = await apiResponsePromise;
|
||||
expect(apiResponse.status()).toBeLessThan(400);
|
||||
const body = await apiResponse.json();
|
||||
expect(body.status).toBe('provisioning');
|
||||
|
||||
// Should redirect back to establishments list
|
||||
await page.waitForURL('**/super-admin/establishments', { timeout: 15000 });
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment appears in the list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// The establishment created in previous test should be visible
|
||||
await expect(page.locator('table')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('td', { hasText: establishmentName })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('[P1] created establishment has a visible status badge', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
|
||||
// Find the row for our establishment
|
||||
const row = page.locator('tr', { has: page.locator(`text=${establishmentName}`) });
|
||||
await expect(row).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Status badge should be visible (provisioning status already verified via API response in creation test)
|
||||
const badge = row.locator('.badge');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).not.toHaveText('');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Form Validation', () => {
|
||||
test('[P2] submit button disabled with empty fields', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[P2] submit button enabled when all fields filled', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
await page.locator('#name').fill('Test School');
|
||||
await page.locator('#adminEmail').fill('admin@test.com');
|
||||
|
||||
const submitButton = page.getByRole('button', { name: /créer l'établissement/i });
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test('[P2] cancel button returns to establishments list', async ({ page }, testInfo) => {
|
||||
const email = getSuperAdminEmail(testInfo.project.name);
|
||||
await loginAsSuperAdmin(page, email);
|
||||
await navigateToEstablishments(page);
|
||||
await navigateToCreateForm(page);
|
||||
|
||||
const cancelLink = page.getByRole('link', { name: /annuler/i });
|
||||
await Promise.all([
|
||||
page.waitForURL('**/super-admin/establishments', { timeout: 10000 }),
|
||||
cancelLink.click()
|
||||
]);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /établissements/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ function resolveDeterministicIds(): { schoolId: string; academicYearId: string }
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
|
||||
330
frontend/e2e/teacher-statistics.spec.ts
Normal file
330
frontend/e2e/teacher-statistics.spec.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execWithRetry, runSql, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
|
||||
|
||||
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 TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
async function loginAs(page: import('@playwright/test').Page, email: string, password: string) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
function querySql(sql: string): string {
|
||||
return execWithRetry(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Smoke tests — navigation only, no data dependency
|
||||
// =========================================================================
|
||||
|
||||
test.describe('Teacher Statistics — Navigation (Story 6.8)', () => {
|
||||
const TEACHER_EMAIL = 'e2e-stats-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'StatsTest123';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
});
|
||||
|
||||
test('should display statistics page with navigation link', async ({ page }) => {
|
||||
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
|
||||
|
||||
const statsLink = page.getByRole('link', { name: /statistiques/i });
|
||||
await expect(statsLink).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await statsLink.click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/teacher\/statistics/);
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('should show overview or empty state', async ({ page }) => {
|
||||
await loginAs(page, TEACHER_EMAIL, TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Page must show either class cards or the "Aucune donnée" empty state
|
||||
const hasCards = await page.getByRole('button', { name: /moyenne/i }).count();
|
||||
const hasEmptyState = await page.getByText('Aucune donnée statistique').count();
|
||||
expect(hasCards + hasEmptyState).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Data-driven tests with seeded evaluations and grades
|
||||
// =========================================================================
|
||||
|
||||
test.describe('Teacher Statistics — Data-Driven (Story 6.8)', () => {
|
||||
const DATA_TEACHER_EMAIL = 'e2e-stats-data-teacher@example.com';
|
||||
const DATA_TEACHER_PASSWORD = 'StatsData123';
|
||||
|
||||
let classId: string;
|
||||
let subjectId: string;
|
||||
let teacherId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
createTestUser('ecole-alpha', DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD, 'ROLE_PROF');
|
||||
|
||||
const { academicYearId } = resolveDeterministicIds(TENANT_ID);
|
||||
|
||||
// Resolve teacher ID
|
||||
const teacherOutput = querySql(
|
||||
`SELECT id FROM users WHERE email = '${DATA_TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
teacherId = teacherOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
|
||||
|
||||
// Find existing class
|
||||
classId = querySql(
|
||||
`SELECT id FROM school_classes WHERE tenant_id = '${TENANT_ID}' LIMIT 1`
|
||||
).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
|
||||
|
||||
// Find existing subject
|
||||
subjectId = querySql(
|
||||
`SELECT id FROM subjects WHERE tenant_id = '${TENANT_ID}' LIMIT 1`
|
||||
).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
|
||||
|
||||
if (!teacherId || !classId || !subjectId) return;
|
||||
|
||||
// Ensure at least 3 students in the class for grade diversity
|
||||
const testStudents = [
|
||||
{ email: 'e2e-stats-student-a@example.com', firstName: 'Alice', lastName: 'Stats' },
|
||||
{ email: 'e2e-stats-student-b@example.com', firstName: 'Bob', lastName: 'Stats' },
|
||||
{ email: 'e2e-stats-student-c@example.com', firstName: 'Charlie', lastName: 'Stats' },
|
||||
];
|
||||
for (const { email, firstName, lastName } of testStudents) {
|
||||
createTestUser('ecole-alpha', email, 'StatsStudent123', 'ROLE_ELEVE');
|
||||
try {
|
||||
runSql(
|
||||
`UPDATE users SET first_name = '${firstName}', last_name = '${lastName}' ` +
|
||||
`WHERE email = '${email}' AND tenant_id = '${TENANT_ID}' AND first_name = ''`
|
||||
);
|
||||
} catch { /* best effort */ }
|
||||
const sidOutput = querySql(
|
||||
`SELECT id FROM users WHERE email = '${email}' AND tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
const sid = sidOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0];
|
||||
if (sid) {
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${classId}', '${academicYearId}', NOW(), NOW()) ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch { /* may exist */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Create teacher assignment
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${teacherId}', '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW()) ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch { /* may exist */ }
|
||||
|
||||
// Create a published evaluation with grades
|
||||
try {
|
||||
const evalIdOutput = querySql(`SELECT gen_random_uuid()::text`);
|
||||
const evalId = evalIdOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
|
||||
|
||||
runSql(
|
||||
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
|
||||
`VALUES ('${evalId}', '${TENANT_ID}', '${classId}', '${subjectId}', '${teacherId}', 'E2E Stats Eval', CURRENT_DATE - INTERVAL '7 days', 20, 1.0, 'published', NOW(), NOW(), NOW()) ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Get students in the class (class_assignments stores student-class links)
|
||||
const studentOutput = querySql(
|
||||
`SELECT ca.user_id FROM class_assignments ca WHERE ca.school_class_id = '${classId}' AND ca.tenant_id = '${TENANT_ID}' LIMIT 3`
|
||||
);
|
||||
const studentIds = [...studentOutput.matchAll(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g)].map(m => m[0]);
|
||||
|
||||
// Find current academic period for student_averages
|
||||
const periodOutput = querySql(
|
||||
`SELECT id FROM academic_periods WHERE tenant_id = '${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1`
|
||||
);
|
||||
const periodId = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0] ?? '';
|
||||
|
||||
const grades = [15.0, 7.0, 12.0];
|
||||
studentIds.forEach((sid, i) => {
|
||||
const grade = grades[i] ?? 10.0;
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${evalId}', '${sid}', ${grade}, 'graded', '${teacherId}', NOW(), NOW()) ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
// Populate student_averages so difficulty badges work
|
||||
if (periodId) {
|
||||
runSql(
|
||||
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${sid}', '${subjectId}', '${periodId}', ${grade}, 1, NOW()) ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
}
|
||||
} catch { /* may exist */ }
|
||||
});
|
||||
} catch { /* may already exist */ }
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
// Cleanup seeded data
|
||||
if (teacherId) {
|
||||
try {
|
||||
runSql(`DELETE FROM student_averages WHERE tenant_id = '${TENANT_ID}' AND subject_id = '${subjectId}'`);
|
||||
runSql(`DELETE FROM grades WHERE tenant_id = '${TENANT_ID}' AND created_by = '${teacherId}'`);
|
||||
runSql(`DELETE FROM evaluations WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`);
|
||||
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}' AND teacher_id = '${teacherId}'`);
|
||||
} catch { /* best effort cleanup */ }
|
||||
}
|
||||
});
|
||||
|
||||
test('should display class cards with evaluation and student counts', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Class card is a button containing "Moyenne" stat label
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Card should contain key stat labels
|
||||
await expect(classCard.getByText('Moyenne')).toBeVisible();
|
||||
await expect(classCard.getByText('Évaluations')).toBeVisible();
|
||||
await expect(classCard.getByText('Élèves')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show export and print buttons in class detail', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click first class card — data is seeded so it must exist
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
|
||||
// Detail view buttons
|
||||
await expect(page.getByRole('button', { name: /exporter csv/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByRole('button', { name: /imprimer/i })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should navigate back from detail to overview', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole('button', { name: /retour/i }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show class detail with student table and histogram', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Histogram section
|
||||
await expect(page.locator('.histogram')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Student table with headers
|
||||
await expect(page.getByRole('columnheader', { name: 'Élève' })).toBeVisible();
|
||||
await expect(page.getByRole('columnheader', { name: 'Moyenne' })).toBeVisible();
|
||||
await expect(page.getByRole('columnheader', { name: 'Statut' })).toBeVisible();
|
||||
|
||||
// At least one student row
|
||||
const studentRows = page.locator('table.student-table tbody tr');
|
||||
const count = await studentRows.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should show difficulty indicators for struggling students', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Student with grade 7.0 (below 8.0 threshold) should have "En difficulté" badge
|
||||
await expect(page.getByText('En difficulté')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should trigger CSV export with correct response headers', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const exportButton = page.getByRole('button', { name: /exporter csv/i });
|
||||
await expect(exportButton).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 15000 });
|
||||
await exportButton.click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toContain('.csv');
|
||||
});
|
||||
|
||||
test('should navigate to student progression view', async ({ page }) => {
|
||||
await loginAs(page, DATA_TEACHER_EMAIL, DATA_TEACHER_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/teacher/statistics`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /mes statistiques/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Chargement des statistiques...')).not.toBeVisible({ timeout: 15000 });
|
||||
|
||||
const classCard = page.locator('button.class-card').first();
|
||||
await expect(classCard).toBeVisible({ timeout: 10000 });
|
||||
await classCard.click();
|
||||
await expect(page.getByRole('button', { name: /retour/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click a student who has grades (test student "Alice Stats" has grade data)
|
||||
const studentLink = page.getByRole('button', { name: /Alice Stats/i });
|
||||
await expect(studentLink).toBeVisible({ timeout: 10000 });
|
||||
await studentLink.click();
|
||||
|
||||
// Progression view should show chart with proper ARIA label
|
||||
await expect(page.getByRole('img', { name: /progression/i })).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,7 @@ test.describe('Expired/Invalid Token Scenarios [P0]', () => {
|
||||
await page.goto(`/activate/${token}`);
|
||||
|
||||
const form = page.locator('form');
|
||||
await expect(form).toBeVisible({ timeout: 5000 });
|
||||
await expect(form).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Fill valid password (must include special char for 5/5 requirements)
|
||||
await page.locator('#password').fill('SecurePass123!');
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
<script lang="ts">
|
||||
export interface BlockedDate {
|
||||
date: string;
|
||||
reason: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = '',
|
||||
onSelect,
|
||||
min = '',
|
||||
blockedDates = [],
|
||||
disabled = false,
|
||||
id = ''
|
||||
}: {
|
||||
value?: string;
|
||||
onSelect: (date: string) => void;
|
||||
min?: string;
|
||||
blockedDates?: BlockedDate[];
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
const DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] as const;
|
||||
|
||||
const MONTHS = [
|
||||
'Janvier',
|
||||
'Février',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Août',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Décembre'
|
||||
] as const;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
weekend: '#9ca3af',
|
||||
holiday: '#ef4444',
|
||||
vacation: '#3b82f6',
|
||||
pedagogical_day: '#f59e0b',
|
||||
rule_hard: '#dc2626',
|
||||
rule_soft: '#f97316'
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
weekend: 'Weekend',
|
||||
holiday: 'Jour férié',
|
||||
vacation: 'Vacances',
|
||||
pedagogical_day: 'J. pédagogique',
|
||||
rule_hard: 'Règle (bloquant)',
|
||||
rule_soft: 'Règle (avertissement)'
|
||||
};
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
let month = $state(new Date().getMonth());
|
||||
let year = $state(new Date().getFullYear());
|
||||
|
||||
// Sync month/year when value changes externally
|
||||
$effect(() => {
|
||||
if (value) {
|
||||
const d = new Date(value + 'T00:00:00');
|
||||
month = d.getMonth();
|
||||
year = d.getFullYear();
|
||||
}
|
||||
});
|
||||
|
||||
let monthLabel = $derived(`${MONTHS[month]} ${year}`);
|
||||
let daysInMonth = $derived(new Date(year, month + 1, 0).getDate());
|
||||
|
||||
let firstDayOffset = $derived.by(() => {
|
||||
const jsDay = new Date(year, month, 1).getDay();
|
||||
return jsDay === 0 ? 6 : jsDay - 1;
|
||||
});
|
||||
|
||||
// Build a Set for O(1) lookup of blocked dates
|
||||
let blockedDateMap = $derived.by(() => {
|
||||
const map = new Map<string, BlockedDate>();
|
||||
for (const bd of blockedDates) {
|
||||
map.set(bd.date, bd);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
interface DayCell {
|
||||
day: number;
|
||||
dateStr: string;
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
isBlocked: boolean;
|
||||
isWarning: boolean;
|
||||
blockedReason: string;
|
||||
blockedType: string;
|
||||
isBeforeMin: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
let calendarGrid = $derived.by(() => {
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const cells: (DayCell | null)[] = [];
|
||||
|
||||
for (let i = 0; i < firstDayOffset; i++) {
|
||||
cells.push(null);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const dayOfWeek = (firstDayOffset + d - 1) % 7;
|
||||
const isWeekend = dayOfWeek >= 5;
|
||||
|
||||
const blocked = blockedDateMap.get(dateStr);
|
||||
const isSoftRule = blocked?.type === 'rule_soft';
|
||||
const isBlocked = isWeekend || (!!blocked && !isSoftRule);
|
||||
const isBeforeMin = min !== '' && dateStr < min;
|
||||
|
||||
cells.push({
|
||||
day: d,
|
||||
dateStr,
|
||||
isToday: dateStr === todayStr,
|
||||
isWeekend,
|
||||
isBlocked,
|
||||
isWarning: isSoftRule,
|
||||
blockedReason: blocked?.reason ?? (isWeekend ? 'Weekend' : ''),
|
||||
blockedType: blocked?.type ?? (isWeekend ? 'weekend' : ''),
|
||||
isBeforeMin,
|
||||
isSelected: dateStr === value
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
});
|
||||
|
||||
// Determine which blocked types are visible this month for the legend
|
||||
let visibleBlockedTypes = $derived.by(() => {
|
||||
const types = new Set<string>();
|
||||
for (const cell of calendarGrid) {
|
||||
if ((cell?.isBlocked || cell?.isWarning) && cell.blockedType) {
|
||||
types.add(cell.blockedType);
|
||||
}
|
||||
}
|
||||
return [...types];
|
||||
});
|
||||
|
||||
// Navigation bounds: don't go before current month or beyond 3 months ahead
|
||||
let canGoPrev = $derived.by(() => {
|
||||
const now = new Date();
|
||||
return year > now.getFullYear() || (year === now.getFullYear() && month > now.getMonth());
|
||||
});
|
||||
|
||||
let canGoNext = $derived.by(() => {
|
||||
const limit = new Date();
|
||||
limit.setMonth(limit.getMonth() + 3);
|
||||
return year < limit.getFullYear() || (year === limit.getFullYear() && month < limit.getMonth());
|
||||
});
|
||||
|
||||
function prevMonth() {
|
||||
if (!canGoPrev) return;
|
||||
if (month === 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else {
|
||||
month--;
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (!canGoNext) return;
|
||||
if (month === 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
} else {
|
||||
month++;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDate(dateStr: string) {
|
||||
onSelect(dateStr);
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function formatDisplayDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.calendar-date-picker')) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="calendar-date-picker" class:disabled onkeydown={handleKeydown}>
|
||||
{#if id}
|
||||
<input
|
||||
type="date"
|
||||
{id}
|
||||
value={value}
|
||||
{min}
|
||||
onchange={(e) => onSelect((e.target as HTMLInputElement).value)}
|
||||
class="sr-only-input"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="picker-trigger"
|
||||
onclick={() => {
|
||||
if (!disabled) isOpen = !isOpen;
|
||||
}}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isOpen}
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{#if value}
|
||||
<span class="picker-value">{formatDisplayDate(value)}</span>
|
||||
{:else}
|
||||
<span class="picker-placeholder">Choisir une date</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="calendar-dropdown" role="dialog" aria-label="Calendrier">
|
||||
<div class="calendar-header">
|
||||
<button type="button" class="nav-btn" onclick={prevMonth} disabled={!canGoPrev} aria-label="Mois précédent">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="month-label">{monthLabel}</span>
|
||||
<button type="button" class="nav-btn" onclick={nextMonth} disabled={!canGoNext} aria-label="Mois suivant">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid" role="grid" aria-label="Jours du mois">
|
||||
<div class="calendar-row day-names" role="row">
|
||||
{#each DAYS as day}
|
||||
<div class="day-name" role="columnheader">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each { length: Math.ceil(calendarGrid.length / 7) } as _, weekIndex}
|
||||
<div class="calendar-row" role="row">
|
||||
{#each calendarGrid.slice(weekIndex * 7, weekIndex * 7 + 7) as cell}
|
||||
{#if cell === null}
|
||||
<div class="day-cell empty" role="gridcell"></div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="day-cell"
|
||||
class:today={cell.isToday}
|
||||
class:selected={cell.isSelected}
|
||||
class:weekend={cell.isWeekend}
|
||||
class:blocked={cell.isBlocked}
|
||||
class:warning={cell.isWarning}
|
||||
class:before-min={cell.isBeforeMin}
|
||||
disabled={cell.isBlocked || cell.isBeforeMin}
|
||||
onclick={() => selectDate(cell.dateStr)}
|
||||
title={cell.isBlocked || cell.isWarning
|
||||
? cell.blockedReason
|
||||
: cell.isBeforeMin
|
||||
? 'Date antérieure au minimum autorisé'
|
||||
: ''}
|
||||
role="gridcell"
|
||||
aria-selected={cell.isSelected}
|
||||
aria-disabled={cell.isBlocked || cell.isBeforeMin}
|
||||
>
|
||||
{cell.day}
|
||||
{#if (cell.isBlocked || cell.isWarning) && !cell.isWeekend}
|
||||
<span
|
||||
class="blocked-dot"
|
||||
style="background-color: {TYPE_COLORS[cell.blockedType] || '#9ca3af'}"
|
||||
></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if visibleBlockedTypes.length > 0}
|
||||
<div class="calendar-legend">
|
||||
{#each visibleBlockedTypes as type}
|
||||
<div class="legend-item">
|
||||
<span class="legend-dot" style="background-color: {TYPE_COLORS[type] || '#9ca3af'}"></span>
|
||||
<span class="legend-label">{TYPE_LABELS[type] || type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sr-only-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.calendar-date-picker {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendar-date-picker.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.picker-trigger:hover:not(:disabled) {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.picker-trigger:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.picker-placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.calendar-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.month-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: none;
|
||||
border-radius: 0.25rem;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.calendar-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.day-names {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.day-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #374151;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.day-cell.empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.day-cell:not(.empty):not(:disabled):hover {
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 0 0 1px #3b82f6;
|
||||
}
|
||||
|
||||
.day-cell.selected {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-cell.selected:hover {
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-cell.weekend {
|
||||
color: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.blocked:not(.weekend) {
|
||||
color: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.day-cell.warning {
|
||||
color: #9a3412;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.day-cell.before-min {
|
||||
color: #e5e7eb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.blocked-dot {
|
||||
position: absolute;
|
||||
bottom: 0.125rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@
|
||||
let files = $state<UploadedFile[]>(existingFiles);
|
||||
let pendingFiles = $state<{ name: string; size: number }[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
let isDragging = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
$effect(() => {
|
||||
@@ -52,6 +53,15 @@
|
||||
return '📎';
|
||||
}
|
||||
|
||||
function formatFileType(mimeType: string): string {
|
||||
if (mimeType === 'application/pdf') return 'PDF';
|
||||
if (mimeType === 'image/jpeg') return 'JPEG';
|
||||
if (mimeType === 'image/png') return 'PNG';
|
||||
if (mimeType.includes('wordprocessingml')) return 'DOCX';
|
||||
const parts = mimeType.split('/');
|
||||
return parts[1]?.toUpperCase() ?? mimeType;
|
||||
}
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
return `Type de fichier non accepté : ${file.type}.`;
|
||||
@@ -62,14 +72,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = input.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
async function processFiles(fileList: globalThis.FileList) {
|
||||
error = null;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
for (const file of fileList) {
|
||||
const validationError = validateFile(file);
|
||||
if (validationError) {
|
||||
error = validationError;
|
||||
@@ -87,10 +93,37 @@
|
||||
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = input.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) return;
|
||||
|
||||
await processFiles(selectedFiles);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const related = event.relatedTarget as globalThis.Node | null;
|
||||
if (related && target.contains(related)) return;
|
||||
isDragging = false;
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging = false;
|
||||
const droppedFiles = event.dataTransfer?.files;
|
||||
if (!droppedFiles || droppedFiles.length === 0) return;
|
||||
await processFiles(droppedFiles);
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
error = null;
|
||||
|
||||
@@ -114,6 +147,7 @@
|
||||
<li class="file-item">
|
||||
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
|
||||
<span class="file-name">{file.filename}</span>
|
||||
<span class="file-type">{formatFileType(file.mimeType)}</span>
|
||||
<span class="file-size">{formatFileSize(file.fileSize)}</span>
|
||||
{#if !disabled && showDelete}
|
||||
<button
|
||||
@@ -140,22 +174,38 @@
|
||||
{/if}
|
||||
|
||||
{#if !disabled}
|
||||
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:drop-zone-active={isDragging}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput.click()}
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
Ajouter un fichier
|
||||
</button>
|
||||
<p class="drop-zone-text">
|
||||
Glissez-déposez vos fichiers ici ou
|
||||
<button type="button" class="drop-zone-browse" onclick={(e) => { e.stopPropagation(); fileInput.click(); }}>
|
||||
parcourir
|
||||
</button>
|
||||
</p>
|
||||
<p class="upload-hint">{hint}</p>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
onchange={handleFileSelect}
|
||||
multiple
|
||||
class="file-input-hidden"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<p class="upload-hint">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -216,6 +266,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
color: #6b7280;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
@@ -249,25 +309,51 @@
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
display: inline-flex;
|
||||
.drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px dashed #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
background: #fafafa;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background-color 0.15s;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.upload-btn:hover {
|
||||
border-color: #3b82f6;
|
||||
.drop-zone:hover {
|
||||
border-color: #93c5fd;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.drop-zone-active {
|
||||
border-color: #3b82f6;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.drop-zone-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.drop-zone-browse {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #2563eb;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.drop-zone-browse:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
{#if isProf}
|
||||
<a href="/dashboard/teacher/homework" class="nav-link" class:active={pathname === '/dashboard/teacher/homework'}>Devoirs</a>
|
||||
<a href="/dashboard/teacher/evaluations" class="nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>Évaluations</a>
|
||||
<a href="/dashboard/teacher/statistics" class="nav-link" class:active={pathname.startsWith('/dashboard/teacher/statistics')}>Mes statistiques</a>
|
||||
{/if}
|
||||
{#if isEleve}
|
||||
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
||||
@@ -161,6 +162,9 @@
|
||||
<a href="/dashboard/teacher/evaluations" class="mobile-nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>
|
||||
Évaluations
|
||||
</a>
|
||||
<a href="/dashboard/teacher/statistics" class="mobile-nav-link" class:active={pathname.startsWith('/dashboard/teacher/statistics')}>
|
||||
Mes statistiques
|
||||
</a>
|
||||
{/if}
|
||||
{#if isEleve}
|
||||
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import CalendarDatePicker from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
|
||||
import type { BlockedDate } from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
|
||||
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
|
||||
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
@@ -86,6 +88,14 @@
|
||||
let isSubmitting = $state(false);
|
||||
let newPendingFiles = $state<File[]>([]);
|
||||
|
||||
// Blocked dates for calendar
|
||||
let calendarBlockedDates = $state<BlockedDate[]>([]);
|
||||
|
||||
// Edit calendar: rules don't apply (homework already assigned to students)
|
||||
let editCalendarBlockedDates = $derived(
|
||||
calendarBlockedDates.filter((d) => !d.type.startsWith('rule_'))
|
||||
);
|
||||
|
||||
// Attachments
|
||||
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
|
||||
|
||||
@@ -96,6 +106,7 @@
|
||||
let editDescription = $state('');
|
||||
let editDueDate = $state('');
|
||||
let isUpdating = $state(false);
|
||||
let editError = $state<string | null>(null);
|
||||
|
||||
// Delete modal
|
||||
let showDeleteModal = $state(false);
|
||||
@@ -158,7 +169,10 @@
|
||||
|
||||
// Load on mount
|
||||
$effect(() => {
|
||||
untrack(() => loadAll());
|
||||
untrack(() => {
|
||||
loadAll();
|
||||
loadBlockedDates();
|
||||
});
|
||||
});
|
||||
|
||||
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||
@@ -168,6 +182,28 @@
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadBlockedDates() {
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const today = new Date();
|
||||
const startDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
// Load 3 months ahead (stays within current academic year boundary)
|
||||
const endDate = new Date(today.getFullYear(), today.getMonth() + 4, 0);
|
||||
const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const res = await authenticatedFetch(
|
||||
`${apiUrl}/schedule/blocked-dates?startDate=${startDate}&endDate=${endDateStr}`
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
calendarBlockedDates = extractCollection<BlockedDate>(data);
|
||||
}
|
||||
} catch {
|
||||
// Silent fail — calendar will work without blocked dates
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignments() {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
if (!userId) return;
|
||||
@@ -602,6 +638,7 @@
|
||||
editDescription = hw.description ?? '';
|
||||
editDueDate = hw.dueDate;
|
||||
editAttachments = [];
|
||||
editError = null;
|
||||
showEditModal = true;
|
||||
|
||||
// Charger les pièces jointes existantes en arrière-plan
|
||||
@@ -619,7 +656,7 @@
|
||||
|
||||
try {
|
||||
isUpdating = true;
|
||||
error = null;
|
||||
editError = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -644,7 +681,7 @@
|
||||
closeEditModal();
|
||||
await loadHomeworks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
|
||||
editError = e instanceof Error ? e.message : 'Erreur lors de la modification';
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
@@ -986,14 +1023,14 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hw-due-date">Date d'échéance *</label>
|
||||
<input
|
||||
type="date"
|
||||
<label>Date d'échéance *</label>
|
||||
<CalendarDatePicker
|
||||
id="hw-due-date"
|
||||
value={newDueDate}
|
||||
oninput={(e) => handleDueDateChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
onSelect={(date) => handleDueDateChange(date)}
|
||||
min={ruleConformMinDate || minDueDate}
|
||||
blockedDates={calendarBlockedDates}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{#if dueDateError}
|
||||
<small class="form-hint form-hint-error">{dueDateError}</small>
|
||||
@@ -1001,8 +1038,6 @@
|
||||
<small class="form-hint form-hint-rule">
|
||||
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</small>
|
||||
{:else}
|
||||
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1073,6 +1108,14 @@
|
||||
handleUpdate();
|
||||
}}
|
||||
>
|
||||
{#if editError}
|
||||
<div class="alert alert-error" style="margin-bottom: 1rem;">
|
||||
<span class="alert-icon">⚠</span>
|
||||
{editError}
|
||||
<button class="alert-close" onclick={() => (editError = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-info">
|
||||
<span class="info-label">Classe :</span>
|
||||
<span>{editHomework.className ?? getClassName(editHomework.classId)}</span>
|
||||
@@ -1103,8 +1146,15 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-due-date">Date d'échéance *</label>
|
||||
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
|
||||
<label>Date d'échéance *</label>
|
||||
<CalendarDatePicker
|
||||
id="edit-due-date"
|
||||
value={editDueDate}
|
||||
onSelect={(date) => (editDueDate = date)}
|
||||
min={minDueDate}
|
||||
blockedDates={editCalendarBlockedDates}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if editHomework}
|
||||
|
||||
767
frontend/src/routes/dashboard/teacher/statistics/+page.svelte
Normal file
767
frontend/src/routes/dashboard/teacher/statistics/+page.svelte
Normal file
@@ -0,0 +1,767 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
|
||||
interface ClassOverview {
|
||||
classId: string;
|
||||
className: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
evaluationCount: number;
|
||||
studentCount: number;
|
||||
average: number | null;
|
||||
successRate: number | null;
|
||||
}
|
||||
|
||||
interface StudentAverage {
|
||||
studentId: string;
|
||||
studentName: string;
|
||||
average: number | null;
|
||||
inDifficulty: boolean;
|
||||
trend: string;
|
||||
}
|
||||
|
||||
interface ClassDetail {
|
||||
average: number | null;
|
||||
successRate: number;
|
||||
distribution: number[];
|
||||
evolution: { month: string; average: number }[];
|
||||
students: StudentAverage[];
|
||||
}
|
||||
|
||||
interface GradePoint {
|
||||
date: string;
|
||||
value: number;
|
||||
evaluationTitle: string;
|
||||
}
|
||||
|
||||
interface StudentProgression {
|
||||
grades: GradePoint[];
|
||||
trendLine: { slope: number; intercept: number } | null;
|
||||
}
|
||||
|
||||
interface EvaluationDifficulty {
|
||||
evaluationId: string;
|
||||
title: string;
|
||||
classId: string;
|
||||
className: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
date: string;
|
||||
average: number | null;
|
||||
gradedCount: number;
|
||||
subjectAverage: number | null;
|
||||
percentile: number | null;
|
||||
}
|
||||
|
||||
type View = 'overview' | 'class-detail' | 'student-progression';
|
||||
|
||||
let isLoading = $state(true);
|
||||
let error = $state('');
|
||||
let overview = $state<ClassOverview[]>([]);
|
||||
let evaluationDifficulties = $state<EvaluationDifficulty[]>([]);
|
||||
|
||||
let currentView = $state<View>('overview');
|
||||
let selectedClass = $state<ClassOverview | null>(null);
|
||||
let classDetail = $state<ClassDetail | null>(null);
|
||||
let isLoadingDetail = $state(false);
|
||||
|
||||
let selectedStudent = $state<StudentAverage | null>(null);
|
||||
let studentProgression = $state<StudentProgression | null>(null);
|
||||
let isLoadingProgression = $state(false);
|
||||
|
||||
const distributionLabels = ['0-2.5', '2.5-5', '5-7.5', '7.5-10', '10-12.5', '12.5-15', '15-17.5', '17.5-20'];
|
||||
const monthLabels: Record<string, string> = {
|
||||
'01': 'Jan', '02': 'Fév', '03': 'Mar', '04': 'Avr', '05': 'Mai', '06': 'Juin',
|
||||
'07': 'Juil', '08': 'Août', '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Déc',
|
||||
};
|
||||
|
||||
let maxBin = $derived(classDetail ? Math.max(...classDetail.distribution, 1) : 1);
|
||||
|
||||
let evoValues = $derived(classDetail ? classDetail.evolution.map(e => e.average) : []);
|
||||
let evoMinY = $derived(evoValues.length > 0 ? Math.max(0, Math.min(...evoValues) - 2) : 0);
|
||||
let evoMaxY = $derived(evoValues.length > 0 ? Math.min(20, Math.max(...evoValues) + 2) : 20);
|
||||
let evoRangeY = $derived(evoMaxY - evoMinY || 1);
|
||||
|
||||
let progGrades = $derived(studentProgression ? studentProgression.grades : []);
|
||||
let progValues = $derived(progGrades.map(g => g.value));
|
||||
let progMinY = $derived(progValues.length > 0 ? Math.max(0, Math.min(...progValues) - 2) : 0);
|
||||
let progMaxY = $derived(progValues.length > 0 ? Math.min(20, Math.max(...progValues) + 2) : 20);
|
||||
let progRangeY = $derived(progMaxY - progMinY || 1);
|
||||
let progWidth = $derived(progGrades.length * 80);
|
||||
|
||||
function chartY(value: number, minY: number, rangeY: number): number {
|
||||
return 190 - ((value - minY) / rangeY) * 180;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadOverview();
|
||||
loadEvaluationDifficulties();
|
||||
});
|
||||
|
||||
async function loadOverview() {
|
||||
isLoading = true;
|
||||
error = '';
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/statistics`);
|
||||
if (!response.ok) throw new Error('Erreur lors du chargement des statistiques');
|
||||
const data = await response.json();
|
||||
overview = data.classes ?? [];
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvaluationDifficulties() {
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/statistics/evaluations`);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
evaluationDifficulties = data.evaluations ?? [];
|
||||
} catch {
|
||||
// Non-bloquant, la section sera simplement vide
|
||||
}
|
||||
}
|
||||
|
||||
async function selectClass(cls: ClassOverview) {
|
||||
error = '';
|
||||
classDetail = null;
|
||||
selectedClass = cls;
|
||||
currentView = 'class-detail';
|
||||
isLoadingDetail = true;
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams({ subjectId: cls.subjectId });
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/statistics/classes/${cls.classId}?${params}`);
|
||||
if (!response.ok) throw new Error('Erreur lors du chargement du détail');
|
||||
const data: ClassDetail = await response.json();
|
||||
classDetail = {
|
||||
average: data.average ?? null,
|
||||
successRate: data.successRate ?? 0,
|
||||
distribution: data.distribution ?? [],
|
||||
evolution: data.evolution ?? [],
|
||||
students: (data.students ?? []).map((s) => ({
|
||||
...s,
|
||||
average: s.average ?? null,
|
||||
inDifficulty: s.inDifficulty ?? false,
|
||||
trend: s.trend ?? 'stable',
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoadingDetail = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectStudent(student: StudentAverage) {
|
||||
if (!selectedClass) return;
|
||||
error = '';
|
||||
studentProgression = null;
|
||||
selectedStudent = student;
|
||||
currentView = 'student-progression';
|
||||
isLoadingProgression = true;
|
||||
try {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams({
|
||||
subjectId: selectedClass.subjectId,
|
||||
classId: selectedClass.classId,
|
||||
});
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/statistics/students/${student.studentId}?${params}`);
|
||||
if (!response.ok) throw new Error('Erreur lors du chargement de la progression');
|
||||
const data: StudentProgression = await response.json();
|
||||
studentProgression = {
|
||||
grades: data.grades ?? [],
|
||||
trendLine: data.trendLine ?? null,
|
||||
};
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoadingProgression = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
error = '';
|
||||
if (currentView === 'student-progression') {
|
||||
currentView = 'class-detail';
|
||||
selectedStudent = null;
|
||||
studentProgression = null;
|
||||
} else if (currentView === 'class-detail') {
|
||||
currentView = 'overview';
|
||||
selectedClass = null;
|
||||
classDetail = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
if (!selectedClass) return;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams({
|
||||
classId: selectedClass.classId,
|
||||
subjectId: selectedClass.subjectId,
|
||||
className: selectedClass.className,
|
||||
subjectName: selectedClass.subjectName,
|
||||
});
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/statistics/export?${params}`);
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `statistiques-${selectedClass.className}-${selectedClass.subjectName}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function formatMonth(m: string): string {
|
||||
const monthKey = m.split('-')[1] as string | undefined;
|
||||
return (monthKey && monthLabels[monthKey]) ?? m;
|
||||
}
|
||||
|
||||
function trendIcon(trend: string): string {
|
||||
if (trend === 'improving') return '\u2191';
|
||||
if (trend === 'declining') return '\u2193';
|
||||
return '\u2192';
|
||||
}
|
||||
|
||||
function trendClass(trend: string): string {
|
||||
if (trend === 'improving') return 'trend-up';
|
||||
if (trend === 'declining') return 'trend-down';
|
||||
return 'trend-stable';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mes statistiques - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="statistics-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
{#if currentView !== 'overview'}
|
||||
<button class="back-link" onclick={goBack}>← Retour</button>
|
||||
{/if}
|
||||
<h1>
|
||||
{#if currentView === 'overview'}
|
||||
Mes statistiques
|
||||
{:else if currentView === 'class-detail' && selectedClass}
|
||||
{selectedClass.className} — {selectedClass.subjectName}
|
||||
{:else if currentView === 'student-progression' && selectedStudent}
|
||||
Progression de {selectedStudent.studentName}
|
||||
{/if}
|
||||
</h1>
|
||||
</div>
|
||||
{#if currentView === 'class-detail'}
|
||||
<div class="header-actions">
|
||||
<button class="btn-secondary" onclick={exportCsv}>Exporter CSV</button>
|
||||
<button class="btn-secondary" onclick={() => window.print()}>Imprimer / PDF</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error-banner" role="alert">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- OVERVIEW -->
|
||||
{#if currentView === 'overview'}
|
||||
{#if isLoading}
|
||||
<div class="loading">Chargement des statistiques...</div>
|
||||
{:else if overview.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Aucune donnée statistique disponible pour la période en cours.</p>
|
||||
<p>Les statistiques apparaîtront après la publication de notes.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overview-grid">
|
||||
{#each overview as cls}
|
||||
<button class="class-card" onclick={() => selectClass(cls)}>
|
||||
<div class="card-header">
|
||||
<span class="card-class">{cls.className}</span>
|
||||
<span class="card-subject">{cls.subjectName}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{cls.average != null ? cls.average.toFixed(1) : '—'}</span>
|
||||
<span class="stat-label">Moyenne</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{cls.successRate != null ? `${cls.successRate.toFixed(0)}%` : '—'}</span>
|
||||
<span class="stat-label">Réussite</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{cls.evaluationCount}</span>
|
||||
<span class="stat-label">Évaluations</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{cls.studentCount}</span>
|
||||
<span class="stat-label">Élèves</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- D1: Evaluation Difficulty Section (AC4) -->
|
||||
{#if evaluationDifficulties.length > 0}
|
||||
<section class="chart-section" style="margin-top: 1.5rem;">
|
||||
<h2>Mes évaluations — Analyse de difficulté</h2>
|
||||
<div class="student-table-wrapper">
|
||||
<table class="student-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Évaluation</th>
|
||||
<th>Classe</th>
|
||||
<th>Matière</th>
|
||||
<th>Date</th>
|
||||
<th>Moyenne</th>
|
||||
<th>Moy. matière</th>
|
||||
<th>Percentile</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each evaluationDifficulties as ev}
|
||||
<tr>
|
||||
<td>{ev.title}</td>
|
||||
<td>{ev.className}</td>
|
||||
<td>{ev.subjectName}</td>
|
||||
<td class="text-center">{ev.date}</td>
|
||||
<td class="text-center">{ev.average != null ? ev.average.toFixed(1) : '—'}</td>
|
||||
<td class="text-center">{ev.subjectAverage != null ? ev.subjectAverage.toFixed(1) : '—'}</td>
|
||||
<td class="text-center">{ev.percentile != null ? `${ev.percentile.toFixed(0)}%` : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- CLASS DETAIL -->
|
||||
{#if currentView === 'class-detail'}
|
||||
{#if isLoadingDetail}
|
||||
<div class="loading">Chargement du détail...</div>
|
||||
{:else if classDetail}
|
||||
<div class="detail-layout">
|
||||
<!-- Summary Cards -->
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span class="summary-value">{classDetail.average != null ? classDetail.average.toFixed(2) : '—'}</span>
|
||||
<span class="summary-label">Moyenne de classe</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-value">{classDetail.successRate.toFixed(0)}%</span>
|
||||
<span class="summary-label">Taux de réussite (≥ 10/20)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribution Histogram -->
|
||||
<section class="chart-section">
|
||||
<h2>Répartition des notes</h2>
|
||||
<div class="histogram">
|
||||
{#each classDetail.distribution as count, i}
|
||||
<div class="histogram-bar-wrapper">
|
||||
<div class="histogram-bar" style="height: {(count / maxBin) * 100}%">
|
||||
{#if count > 0}<span class="bar-count">{count}</span>{/if}
|
||||
</div>
|
||||
<span class="bar-label">{distributionLabels[i]}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Evolution Chart (P13: fallback message) -->
|
||||
{#if classDetail.evolution.length >= 2}
|
||||
<section class="chart-section">
|
||||
<h2>Évolution sur l'année</h2>
|
||||
<div class="line-chart" role="img" aria-label="Graphique d'évolution des moyennes">
|
||||
<svg viewBox="0 0 {classDetail.evolution.length * 80} 200" class="chart-svg">
|
||||
{#each [0, 0.25, 0.5, 0.75, 1] as ratio}
|
||||
<line x1="0" y1={ratio * 180 + 10} x2={classDetail.evolution.length * 80} y2={ratio * 180 + 10} stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="0" y={ratio * 180 + 6} font-size="10" fill="#9ca3af">{(evoMaxY - ratio * evoRangeY).toFixed(1)}</text>
|
||||
{/each}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2.5"
|
||||
points={classDetail.evolution.map((e, i) => `${i * 80 + 40},${chartY(e.average, evoMinY, evoRangeY)}`).join(' ')}
|
||||
/>
|
||||
{#each classDetail.evolution as e, i}
|
||||
<circle cx={i * 80 + 40} cy={chartY(e.average, evoMinY, evoRangeY)} r="4" fill="#3b82f6"/>
|
||||
<text x={i * 80 + 40} y={chartY(e.average, evoMinY, evoRangeY) - 10} text-anchor="middle" font-size="11" fill="#1e40af">{e.average.toFixed(1)}</text>
|
||||
<text x={i * 80 + 40} y="198" text-anchor="middle" font-size="10" fill="#6b7280">{formatMonth(e.month)}</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<section class="chart-section">
|
||||
<h2>Évolution sur l'année</h2>
|
||||
<p class="empty-section-text">Pas assez de données pour afficher l'évolution (minimum 2 mois).</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Student List -->
|
||||
<section class="chart-section">
|
||||
<h2>Moyennes par élève</h2>
|
||||
<div class="student-table-wrapper">
|
||||
<table class="student-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Élève</th>
|
||||
<th>Moyenne</th>
|
||||
<th>Statut</th>
|
||||
<th>Tendance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each classDetail.students as student}
|
||||
<tr class:in-difficulty={student.inDifficulty}>
|
||||
<td>
|
||||
<button class="student-link" onclick={() => selectStudent(student)}>
|
||||
{student.studentName}
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-center">{student.average != null ? student.average.toFixed(2) : '—'}</td>
|
||||
<td class="text-center">
|
||||
{#if student.inDifficulty}
|
||||
<span class="badge badge-danger">En difficulté</span>
|
||||
{:else}
|
||||
<span class="badge badge-success">OK</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="trend {trendClass(student.trend)}">{trendIcon(student.trend)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- STUDENT PROGRESSION -->
|
||||
{#if currentView === 'student-progression'}
|
||||
{#if isLoadingProgression}
|
||||
<div class="loading">Chargement de la progression...</div>
|
||||
{:else if studentProgression && studentProgression.grades.length > 0}
|
||||
<section class="chart-section">
|
||||
<h2>Notes sur l'année</h2>
|
||||
<div class="line-chart" role="img" aria-label="Graphique de progression de l'élève">
|
||||
<svg viewBox="0 0 {progWidth} 220" class="chart-svg">
|
||||
{#each [0, 0.25, 0.5, 0.75, 1] as ratio}
|
||||
<line x1="0" y1={ratio * 180 + 10} x2={progWidth} y2={ratio * 180 + 10} stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="0" y={ratio * 180 + 6} font-size="10" fill="#9ca3af">{(progMaxY - ratio * progRangeY).toFixed(1)}</text>
|
||||
{/each}
|
||||
{#if studentProgression.trendLine}
|
||||
<line
|
||||
x1="40"
|
||||
y1={chartY(studentProgression.trendLine.intercept + studentProgression.trendLine.slope, progMinY, progRangeY)}
|
||||
x2={(progGrades.length - 1) * 80 + 40}
|
||||
y2={chartY(studentProgression.trendLine.intercept + studentProgression.trendLine.slope * progGrades.length, progMinY, progRangeY)}
|
||||
stroke="#ef4444" stroke-width="1.5" stroke-dasharray="6,4" opacity="0.7"
|
||||
/>
|
||||
{/if}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke-width="2.5"
|
||||
points={progGrades.map((g, i) => `${i * 80 + 40},${chartY(g.value, progMinY, progRangeY)}`).join(' ')}
|
||||
/>
|
||||
{#each progGrades as g, i}
|
||||
<circle cx={i * 80 + 40} cy={chartY(g.value, progMinY, progRangeY)} r="4" fill="#10b981"/>
|
||||
<text x={i * 80 + 40} y={chartY(g.value, progMinY, progRangeY) - 10} text-anchor="middle" font-size="11" fill="#065f46">{g.value.toFixed(1)}</text>
|
||||
<text x={i * 80 + 40} y="210" text-anchor="middle" font-size="9" fill="#6b7280">{g.date.slice(5)}</text>
|
||||
{/each}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="grade-legend">
|
||||
{#each progGrades as g}
|
||||
<div class="grade-legend-item">
|
||||
<span class="legend-date">{g.date}</span>
|
||||
<span class="legend-title">{g.evaluationTitle}</span>
|
||||
<span class="legend-value">{g.value.toFixed(1)}/20</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="empty-state">Aucune note publiée pour cet élève dans cette matière.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.statistics-page {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-secondary:hover { background: #f9fafb; }
|
||||
|
||||
.error-banner {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 0.5rem;
|
||||
color: #dc2626;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #9ca3af;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px dashed #d1d5db;
|
||||
}
|
||||
|
||||
/* Overview Grid */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.class-card {
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.class-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.card-class { font-weight: 600; font-size: 1rem; color: #1e293b; }
|
||||
.card-subject { font-size: 0.8125rem; color: #6b7280; }
|
||||
|
||||
.card-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
|
||||
.stat { display: flex; flex-direction: column; align-items: center; }
|
||||
.stat-value { font-size: 1.25rem; font-weight: 700; color: #1e293b; }
|
||||
.stat-label { font-size: 0.6875rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.02em; }
|
||||
|
||||
/* Detail Layout */
|
||||
.detail-layout { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
|
||||
.summary-card {
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.summary-value { font-size: 2rem; font-weight: 700; color: #1e293b; }
|
||||
.summary-label { font-size: 0.875rem; color: #6b7280; }
|
||||
|
||||
/* Chart Section */
|
||||
.chart-section {
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
.chart-section h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.empty-section-text { color: #9ca3af; font-size: 0.875rem; }
|
||||
|
||||
/* Histogram */
|
||||
.histogram {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.25rem;
|
||||
height: 160px;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.histogram-bar-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.histogram-bar {
|
||||
width: 100%;
|
||||
max-width: 48px;
|
||||
background: #3b82f6;
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 2px;
|
||||
position: relative;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.bar-count {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.bar-label {
|
||||
font-size: 0.625rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Line Chart */
|
||||
.line-chart { overflow-x: auto; }
|
||||
.chart-svg { min-width: 400px; width: 100%; height: auto; }
|
||||
|
||||
/* Student Table */
|
||||
.student-table-wrapper { overflow-x: auto; }
|
||||
.student-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.student-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.student-table td { padding: 0.75rem 0.5rem; border-bottom: 1px solid #f3f4f6; }
|
||||
.student-table tr.in-difficulty { background: #fef2f2; }
|
||||
.text-center { text-align: center; }
|
||||
|
||||
.student-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #3b82f6;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-danger { background: #fef2f2; color: #dc2626; }
|
||||
.badge-success { background: #f0fdf4; color: #16a34a; }
|
||||
|
||||
.trend { font-size: 1.125rem; font-weight: 700; }
|
||||
.trend-up { color: #16a34a; }
|
||||
.trend-down { color: #dc2626; }
|
||||
.trend-stable { color: #6b7280; }
|
||||
|
||||
/* Grade Legend */
|
||||
.grade-legend {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.grade-legend-item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.legend-date { color: #6b7280; min-width: 80px; }
|
||||
.legend-title { flex: 1; color: #374151; }
|
||||
.legend-value { font-weight: 600; color: #1e293b; }
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
.page-header .header-actions { display: none; }
|
||||
.back-link { display: none; }
|
||||
.class-card { break-inside: avoid; }
|
||||
.chart-section { break-inside: avoid; }
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 640px) {
|
||||
.statistics-page { padding: 1rem; }
|
||||
.card-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.page-header { flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
@@ -71,8 +71,16 @@
|
||||
</td>
|
||||
<td class="subdomain-cell">{establishment.subdomain}</td>
|
||||
<td>
|
||||
<span class="badge" class:active={establishment.status === 'active'}>
|
||||
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
|
||||
<span
|
||||
class="badge"
|
||||
class:active={establishment.status === 'active'}
|
||||
class:provisioning={establishment.status === 'provisioning'}
|
||||
>
|
||||
{establishment.status === 'active'
|
||||
? 'Actif'
|
||||
: establishment.status === 'provisioning'
|
||||
? 'Provisioning…'
|
||||
: 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="date-cell">
|
||||
@@ -207,6 +215,11 @@
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.badge.provisioning {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user