feat: Configuration du mode de notation par établissement

Les établissements scolaires utilisent des systèmes d'évaluation variés
(notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application
imposait implicitement le mode notes /20, ce qui ne correspondait pas
à la réalité pédagogique de nombreuses écoles.

Cette configuration permet à chaque établissement de choisir son mode
de notation par année scolaire, avec verrouillage automatique dès que
des notes ont été saisies pour éviter les incohérences. Le Score Sérénité
adapte ses pondérations selon le mode choisi (les compétences sont
converties via un mapping, le mode sans notes exclut la composante notes).
This commit is contained in:
2026-02-07 01:06:55 +01:00
parent f19d0ae3ef
commit ff18850a43
51 changed files with 3963 additions and 79 deletions

View File

@@ -25,23 +25,15 @@ test.describe('Classes Management (Story 2.1)', () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// 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' }
);
console.log('Classes E2E test admin user created');
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up all classes for this tenant to ensure Empty State test works
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Classes cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM school_classes WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
});
// Helper to login as admin

View File

@@ -0,0 +1,284 @@
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-pedagogy-admin@example.com';
const ADMIN_PASSWORD = 'PedagogyTest123';
test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
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' }
);
// Reset grading mode to default (numeric_20) to ensure clean state
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php -r "
require 'vendor/autoload.php';
\\$kernel = new App\\Kernel('dev', true);
\\$kernel->boot();
\\$conn = \\$kernel->getContainer()->get('doctrine')->getConnection();
\\$conn->executeStatement('DELETE FROM school_grading_configurations');
" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Table might not exist yet, that's OK — default mode applies
}
});
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 page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}
// =========================================================================
// Navigation
// =========================================================================
test('pedagogy link is visible in admin navigation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
const pedagogyLink = page.getByRole('link', { name: /pédagogie/i });
await expect(pedagogyLink).toBeVisible();
});
test('pedagogy link navigates to pedagogy page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.getByRole('link', { name: /pédagogie/i }).click();
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
});
test('pedagogy card is visible on admin dashboard', async ({ page, browserName }) => {
// Svelte 5 delegated onclick is not triggered by Playwright click on webkit
test.skip(browserName === 'webkit', 'Demo role switcher click not supported on webkit');
await loginAsAdmin(page);
// Switch to admin view in demo dashboard
await page.goto(`${ALPHA_URL}/dashboard`);
const adminButton = page.getByRole('button', { name: /admin/i });
await adminButton.click();
await expect(page.getByRole('heading', { name: /administration/i })).toBeVisible({
timeout: 10000
});
const pedagogyCard = page.getByRole('link', { name: /pédagogie/i });
await expect(pedagogyCard).toBeVisible();
});
// =========================================================================
// AC1: Display grading mode options
// =========================================================================
test('shows page title and subtitle', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await expect(page.getByRole('heading', { name: /mode de notation/i })).toBeVisible();
await expect(page.getByText(/système d'évaluation/i)).toBeVisible();
});
test('shows current mode banner', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
// Wait for loading to finish
await page.waitForLoadState('networkidle');
// Should show "Mode actuel" banner
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
});
test('displays all five grading modes', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// All 5 mode cards should be visible (scope to .mode-card to avoid ambiguous matches)
const modeCards = page.locator('.mode-card');
await expect(modeCards).toHaveCount(5, { timeout: 10000 });
await expect(modeCards.filter({ hasText: /notes \/20/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /notes \/10/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /lettres/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /compétences/i })).toBeVisible();
await expect(modeCards.filter({ hasText: /sans notes/i })).toBeVisible();
});
test('default mode is Notes sur 20', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// The "Notes /20" mode card should be selected (has mode-selected class)
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toBeVisible({ timeout: 10000 });
await expect(selectedCard).toContainText(/notes \/20/i);
});
// =========================================================================
// AC1: Year selector
// =========================================================================
test('can switch between year tabs', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
const tabs = page.getByRole('tab');
// Wait for page to load
await expect(tabs.nth(1)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
await page.waitForLoadState('networkidle');
// Click next year tab
await tabs.nth(2).click();
await expect(tabs.nth(2)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
// Click previous year tab
await tabs.nth(0).click();
await expect(tabs.nth(0)).toHaveAttribute('aria-selected', 'true', { timeout: 10000 });
});
// =========================================================================
// AC2-4: Mode selection and preview
// =========================================================================
test('selecting a different mode shows save button', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Save button should not be visible initially
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
// Click a different mode
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Save and cancel buttons should appear
await expect(page.getByRole('button', { name: /enregistrer/i })).toBeVisible();
await expect(page.getByRole('button', { name: /annuler/i })).toBeVisible();
});
test('cancel button reverts mode selection', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Select a different mode
const lettersCard = page.locator('.mode-card').filter({ hasText: /lettres/i });
await lettersCard.click();
// Click cancel
await page.getByRole('button', { name: /annuler/i }).click();
// Save button should disappear
await expect(page.getByRole('button', { name: /enregistrer/i })).not.toBeVisible();
});
test('selecting competencies mode shows competency-specific description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const competenciesCard = page.locator('.mode-card').filter({ hasText: /compétences/i });
await competenciesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(competenciesCard.locator('.mode-description')).toContainText(
/acquis.*en cours.*non acquis/i
);
});
test('selecting sans notes mode shows no-grades description', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
const noGradesCard = page.locator('.mode-card').filter({ hasText: /sans notes/i });
await noGradesCard.click();
// Check description inside the card (scoped to avoid matching bulletin impact text)
await expect(noGradesCard.locator('.mode-description')).toContainText(
/appréciations textuelles/i
);
});
test('shows bulletin impact preview when mode is selected', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
// Impact section should be visible
await expect(page.getByText(/impact sur les bulletins/i)).toBeVisible({ timeout: 10000 });
});
// =========================================================================
// AC2: Can change to a different mode
// =========================================================================
test('can save a new grading mode', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/pedagogy`);
await page.waitForLoadState('networkidle');
await expect(page.getByText(/mode actuel/i)).toBeVisible({ timeout: 10000 });
// Select "Notes sur 10"
const numeric10Card = page.locator('.mode-card').filter({ hasText: /notes \/10/i });
await numeric10Card.click();
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
// Success message should appear
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
// The selected mode should now be "Notes /10"
const selectedCard = page.locator('.mode-card.mode-selected');
await expect(selectedCard).toContainText(/notes \/10/i);
// Reload the page to verify server-side persistence
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('.mode-card.mode-selected')).toContainText(/notes \/10/i, {
timeout: 10000
});
// Restore default mode for other tests
const numeric20Card = page.locator('.mode-card').filter({ hasText: /notes \/20/i });
await numeric20Card.click();
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByText(/mis à jour avec succès/i)).toBeVisible({ timeout: 10000 });
});
});

View File

@@ -23,23 +23,15 @@ test.describe('Periods Management (Story 2.3)', () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// 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' }
);
console.log('Periods E2E test admin user created');
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up all periods for this tenant
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Periods cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM academic_periods WHERE tenant_id = '${TENANT_ID}'" 2>&1`,
{ encoding: 'utf-8' }
);
});
async function loginAsAdmin(page: import('@playwright/test').Page) {

View File

@@ -25,23 +25,15 @@ test.describe('Subjects Management (Story 2.2)', () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// 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' }
);
console.log('Subjects E2E test admin user created');
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Clean up all subjects for this tenant to ensure Empty State test works
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Subjects cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
});
// Helper to login as admin

View File

@@ -2,6 +2,11 @@
import type { SerenityScore } from '$types';
import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score';
// TODO: Adapter la formule et les poids affichés selon le mode de notation
// (no_grades: 0/50/50, competencies: renommer Notes→Compétences + note mapping).
// À traiter quand le Score Sérénité sera connecté aux vraies données.
// Voir backend SerenityScoreWeights::forMode() pour la logique de pondération.
let {
score,
isEnabled = false,
@@ -16,10 +21,9 @@
onToggleOptIn?: ((enabled: boolean) => void) | undefined;
} = $props();
let localEnabled = $state(isEnabled);
let localEnabled = $state(false);
// Sync local state with parent prop changes
$effect(() => {
$effect.pre(() => {
localEnabled = isEnabled;
});
@@ -43,8 +47,8 @@
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions a11y_no_noninteractive_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header class="modal-header">
<h2 id="modal-title">Comment fonctionne le Score Sérénité ?</h2>

View File

@@ -46,6 +46,11 @@
<span class="action-label">Périodes scolaires</span>
<span class="action-hint">Trimestres et semestres</span>
</a>
<a class="action-card" href="/admin/pedagogy">
<span class="action-icon">🎓</span>
<span class="action-label">Pédagogie</span>
<span class="action-hint">Mode de notation</span>
</a>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">📤</span>
<span class="action-label">Importer des données</span>

View File

@@ -82,6 +82,7 @@ export function createDefaultReporter(options: {
return (metric: VitalMetric) => {
// Log in development
if (options.debug) {
// eslint-disable-next-line no-console
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
}

View File

@@ -27,6 +27,7 @@
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
</script>
<div class="admin-layout">
@@ -40,6 +41,7 @@
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}

View File

@@ -63,6 +63,19 @@
// Derived
let hasConfig = $derived(config !== null && config.periods.length > 0);
// Close modals on Escape key
$effect(() => {
if (!showConfigureModal && !showEditModal) return;
const onKeydown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') {
showConfigureModal = false;
if (showEditModal) closeEditModal();
}
};
document.addEventListener('keydown', onKeydown);
return () => document.removeEventListener('keydown', onKeydown);
});
// Reload when year changes
$effect(() => {
void selectedYear; // Track dependency to re-run on change
@@ -186,13 +199,18 @@
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('fr-FR', {
const [y, m, d] = dateString.split('-');
return new Date(Number(y), Number(m) - 1, Number(d)).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function lastDayOfFeb(year: number): number {
return new Date(year, 2, 0).getDate();
}
function typeLabel(type: string): string {
return type === 'trimester' ? 'Trimestres' : 'Semestres';
}
@@ -314,13 +332,16 @@
<!-- Configure Modal -->
{#if showConfigureModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={() => (showConfigureModal = false)} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="configure-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') showConfigureModal = false; }}
>
<header class="modal-header">
<h2 id="configure-title">Configurer les périodes</h2>
@@ -356,7 +377,7 @@
<strong>T1 :</strong> 1er sept. {startYear} - 30 nov. {startYear}
</p>
<p>
<strong>T2 :</strong> 1er déc. {startYear} - 28 fév. {startYear + 1}
<strong>T2 :</strong> 1er déc. {startYear} - {lastDayOfFeb(startYear + 1)} fév. {startYear + 1}
</p>
<p>
<strong>T3 :</strong> 1er mars {startYear + 1} - 30 juin {startYear + 1}
@@ -395,13 +416,16 @@
<!-- Edit Period Modal -->
{#if showEditModal && editingPeriod}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="edit-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeEditModal(); }}
>
<header class="modal-header">
<h2 id="edit-title">Modifier {editingPeriod.label}</h2>

View File

@@ -217,13 +217,16 @@
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
>
<header class="modal-header">
<h2 id="modal-title">Nouvelle classe</h2>
@@ -285,14 +288,17 @@
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && classToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
onclick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Supprimer la classe</h2>

View File

@@ -0,0 +1,658 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface AvailableMode {
value: string;
label: string;
}
interface GradingModeConfig {
academicYearId: string;
mode: string;
label: string;
scaleMax: number | null;
isNumeric: boolean;
calculatesAverage: boolean;
hasExistingGrades: boolean;
availableModes: AvailableMode[];
}
// State
let config = $state<GradingModeConfig | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let isSubmitting = $state(false);
let selectedMode = $state<string | null>(null);
// Academic year selector
type YearKey = 'previous' | 'current' | 'next';
const yearOptions: { key: YearKey; offset: number }[] = [
{ key: 'previous', offset: -1 },
{ key: 'current', offset: 0 },
{ key: 'next', offset: 1 }
];
let selectedYear = $state<YearKey>('current');
let academicYearId = $derived(selectedYear);
function baseStartYear(): number {
const now = new Date();
return now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1;
}
function schoolYearLabel(offset: number): string {
const sy = baseStartYear() + offset;
return `${sy}-${sy + 1}`;
}
// Derived
let isLocked = $derived(config?.hasExistingGrades === true);
let hasChanges = $derived(selectedMode !== null && config !== null && selectedMode !== config.mode);
// Version counter to discard stale responses on quick year switches
let loadVersion = 0;
// Reload when year changes
$effect(() => {
void selectedYear;
loadConfig();
});
async function loadConfig() {
const myVersion = ++loadVersion;
try {
isLoading = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/academic-years/${academicYearId}/grading-mode`
);
if (myVersion !== loadVersion) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement de la configuration');
}
const data: GradingModeConfig = await response.json();
config = data;
selectedMode = data.mode;
} catch (e) {
if (myVersion !== loadVersion) return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
config = null;
} finally {
if (myVersion === loadVersion) {
isLoading = false;
}
}
}
async function handleSave() {
if (!selectedMode || !hasChanges) return;
try {
isSubmitting = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/academic-years/${academicYearId}/grading-mode`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: selectedMode })
}
);
if (response.status === 409) {
error =
'Impossible de changer le mode de notation : des notes existent pour cette année scolaire. Veuillez attendre la prochaine année.';
selectedMode = config?.mode ?? null;
await loadConfig();
return;
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData['hydra:description'] || errorData.message || 'Erreur lors de la configuration'
);
}
const data: GradingModeConfig = await response.json();
config = data;
selectedMode = data.mode;
successMessage = 'Mode de notation mis à jour avec succès.';
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
function modeDescription(mode: string): string {
const descriptions: Record<string, string> = {
numeric_20: 'Notes sur 20 points. Les moyennes sont calculées classiquement.',
numeric_10: 'Notes sur 10 points. Les moyennes sont calculées classiquement.',
letters: 'Lettres A, B, C, D, E. Les moyennes sont calculées avec un barème.',
competencies:
'Acquis, En cours, Non acquis. Pas de moyenne numérique. Le Score Sérénité utilise un mapping adapté.',
no_grades:
'Appréciations textuelles uniquement. Le Score Sérénité est basé sur les absences et devoirs.'
};
return descriptions[mode] ?? '';
}
function modeBulletinImpact(mode: string): string {
const impacts: Record<string, string> = {
numeric_20: 'Bulletins avec moyennes sur 20, classements et appréciations.',
numeric_10: 'Bulletins avec moyennes sur 10, classements et appréciations.',
letters:
'Bulletins avec lettres (A-E) et moyennes converties selon le barème.',
competencies:
'Bulletins avec niveaux de compétences : Acquis, En cours, Non acquis. Pas de moyenne.',
no_grades: 'Bulletins avec appréciations textuelles uniquement. Aucune note ni compétence.'
};
return impacts[mode] ?? '';
}
</script>
<svelte:head>
<title>Pédagogie - Classeo</title>
</svelte:head>
<div class="pedagogy-page">
<header class="page-header">
<div class="header-content">
<h1>Mode de notation</h1>
<p class="subtitle">
Choisissez le système d'évaluation de votre établissement
</p>
</div>
<div class="header-actions">
<div class="year-selector" role="tablist" aria-label="Année scolaire">
{#each yearOptions as { key, offset } (key)}
<button
role="tab"
class="year-tab"
class:year-tab-active={selectedYear === key}
aria-selected={selectedYear === key}
onclick={() => (selectedYear = key as YearKey)}
>
{schoolYearLabel(offset)}
</button>
{/each}
</div>
</div>
</header>
{#if error}
<div class="alert alert-error" role="alert">
<span class="alert-icon">!</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>x</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success" role="alert">
{successMessage}
<button class="alert-close" onclick={() => (successMessage = null)}>x</button>
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement de la configuration...</p>
</div>
{:else if config}
{#if isLocked}
<div class="alert alert-warning">
<strong>Mode verrouillé :</strong> des notes existent pour cette année scolaire.
Le changement de mode de notation sera possible lors de la configuration de la prochaine année.
</div>
{/if}
<!-- Current mode summary -->
<div class="current-mode-banner">
<div class="banner-content">
<span class="banner-label">Mode actuel</span>
<span class="banner-mode">{config.label}</span>
</div>
{#if config.scaleMax}
<div class="banner-detail">
<span class="detail-number">{config.scaleMax}</span>
<span class="detail-label">points</span>
</div>
{/if}
</div>
<!-- Mode selector -->
<div class="modes-section">
<h2 class="section-title">Modes disponibles</h2>
<div class="modes-grid">
{#each config.availableModes as mode (mode.value)}
<button
class="mode-card"
class:mode-selected={selectedMode === mode.value}
class:mode-locked={isLocked && mode.value !== config.mode}
disabled={isLocked && mode.value !== config.mode}
onclick={() => (selectedMode = mode.value)}
aria-pressed={selectedMode === mode.value}
>
<div class="mode-header">
<span class="mode-radio" class:mode-radio-checked={selectedMode === mode.value}
></span>
<span class="mode-label">{mode.label}</span>
</div>
<p class="mode-description">{modeDescription(mode.value)}</p>
</button>
{/each}
</div>
</div>
<!-- Bulletin impact preview -->
{#if selectedMode}
<div class="preview-section">
<h2 class="section-title">Impact sur les bulletins</h2>
<div class="preview-card">
<p class="preview-text">{modeBulletinImpact(selectedMode)}</p>
</div>
</div>
{/if}
<!-- Save button -->
{#if hasChanges}
<div class="save-bar">
<button
class="btn-secondary"
onclick={() => (selectedMode = config?.mode ?? null)}
disabled={isSubmitting}
>
Annuler
</button>
<button class="btn-primary" onclick={handleSave} disabled={isSubmitting}>
{#if isSubmitting}
Enregistrement...
{:else}
Enregistrer le mode
{/if}
</button>
</div>
{/if}
{/if}
</div>
<style>
.pedagogy-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.year-selector {
display: flex;
background: #f3f4f6;
border-radius: 0.5rem;
padding: 0.25rem;
}
.year-tab {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
cursor: pointer;
transition: all 0.2s;
}
.year-tab-active {
background: white;
color: #1f2937;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.year-tab:hover:not(.year-tab-active) {
color: #374151;
}
/* Current mode banner */
.current-mode-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
border-radius: 0.75rem;
color: white;
margin-bottom: 1.5rem;
}
.banner-label {
font-size: 0.875rem;
opacity: 0.9;
}
.banner-mode {
display: block;
font-size: 1.5rem;
font-weight: 700;
margin-top: 0.25rem;
}
.banner-detail {
text-align: right;
}
.detail-number {
display: block;
font-size: 2rem;
font-weight: 700;
}
.detail-label {
font-size: 0.75rem;
opacity: 0.9;
}
/* Modes section */
.modes-section {
margin-bottom: 1.5rem;
}
.section-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1rem;
}
.modes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.mode-card {
padding: 1.25rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
text-align: left;
width: 100%;
}
.mode-card:hover:not(:disabled) {
border-color: #8b5cf6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.mode-selected {
border-color: #8b5cf6;
background: #f5f3ff;
}
.mode-locked {
opacity: 0.5;
cursor: not-allowed;
}
.mode-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.mode-radio {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #d1d5db;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.2s;
position: relative;
}
.mode-radio-checked {
border-color: #8b5cf6;
}
.mode-radio-checked::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 10px;
height: 10px;
background: #8b5cf6;
border-radius: 50%;
}
.mode-label {
font-weight: 600;
color: #1f2937;
font-size: 1rem;
}
.mode-description {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
line-height: 1.5;
}
/* Preview section */
.preview-section {
margin-bottom: 1.5rem;
}
.preview-card {
padding: 1.25rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
}
.preview-text {
margin: 0;
font-size: 0.9375rem;
color: #374151;
line-height: 1.6;
}
/* Save bar */
.save-bar {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 0;
border-top: 1px solid #e5e7eb;
}
/* Alerts */
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.alert-warning {
padding: 1rem;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
color: #92400e;
margin-bottom: 1rem;
}
.alert-icon {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
background: #dc2626;
color: white;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 700;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #8b5cf6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #7c3aed;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
/* Loading */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
}
.modes-grid {
grid-template-columns: 1fr;
}
.current-mode-banner {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.banner-detail {
text-align: center;
}
}
</style>

View File

@@ -273,13 +273,16 @@
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeModal(); }}
>
<header class="modal-header">
<h2 id="modal-title">Nouvelle matière</h2>
@@ -376,14 +379,17 @@
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && subjectToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
onclick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Supprimer la matière</h2>
@@ -738,8 +744,7 @@
color: #374151;
}
.form-group input[type='text'],
.form-group select {
.form-group input[type='text'] {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;