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

@@ -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;