feat: Permettre à l'élève de consulter ses notes et moyennes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'élève avait accès à ses compétences mais pas à ses notes numériques.
Cette fonctionnalité lui donne une vue complète de sa progression scolaire
avec moyennes par matière, détail par évaluation, statistiques de classe,
et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15).

Les notes ne sont visibles qu'après publication par l'enseignant, ce qui
garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
This commit is contained in:
2026-04-05 16:04:26 +02:00
parent b7dc27f2a5
commit bec211ebf0
59 changed files with 7084 additions and 49 deletions

View File

@@ -109,11 +109,13 @@
{/if}
{#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
<a href="/dashboard/student-grades" class="nav-link" class:active={pathname === '/dashboard/student-grades'}>Mes notes</a>
<a href="/dashboard/student-competencies" class="nav-link" class:active={pathname === '/dashboard/student-competencies'}>Compétences</a>
{/if}
{#if isParent}
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
<a href="/dashboard/parent-homework" class="nav-link" class:active={pathname === '/dashboard/parent-homework'}>Devoirs</a>
<a href="/dashboard/parent-grades" class="nav-link" class:active={pathname === '/dashboard/parent-grades'}>Notes</a>
{/if}
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
@@ -164,6 +166,9 @@
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
Mon emploi du temps
</a>
<a href="/dashboard/student-grades" class="mobile-nav-link" class:active={pathname === '/dashboard/student-grades'}>
Mes notes
</a>
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
Compétences
</a>
@@ -175,6 +180,9 @@
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
Devoirs
</a>
<a href="/dashboard/parent-grades" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-grades'}>
Notes
</a>
{/if}
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
</div>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import ParentGradesView from '$lib/components/organisms/ParentGrades/ParentGradesView.svelte';
</script>
<svelte:head>
<title>Notes des enfants - Classeo</title>
</svelte:head>
<div class="grades-page">
<header class="page-header">
<h1>Notes des enfants</h1>
</header>
<ParentGradesView />
</div>
<style>
.grades-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,695 @@
<script lang="ts">
import type { StudentGrade, StudentAverages, SubjectAverage } from '$lib/features/grades/api/studentGrades';
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
import {
setRevealMode,
isGradeNew,
markGradesSeen,
isGradeRevealed,
revealGrade,
isDiscoverMode
} from '$lib/features/grades/stores/gradePreferences.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let grades: StudentGrade[] = $state([]);
let averages: StudentAverages | null = $state(null);
let isLoading = $state(true);
let error: string | null = $state(null);
let selectedSubjectId: string | null = $state(null);
// Group grades by subject
let subjectGroups = $derived.by(() => {
const map = new Map<string, { subjectName: string; grades: StudentGrade[] }>();
for (const g of grades) {
const key = g.subjectId;
const existing = map.get(key);
if (existing) {
existing.grades.push(g);
} else {
map.set(key, { subjectName: g.subjectName ?? 'Matière inconnue', grades: [g] });
}
}
return map;
});
// Find average for a subject
function subjectAverage(subjectId: string): SubjectAverage | undefined {
return averages?.subjectAverages.find((a) => a.subjectId === subjectId);
}
// Filtered grades for selected subject detail
let detailGrades = $derived.by(() => {
if (!selectedSubjectId) return [];
return grades.filter((g) => g.subjectId === selectedSubjectId);
});
let detailSubjectName = $derived.by(() => {
if (!selectedSubjectId) return '';
return subjectGroups.get(selectedSubjectId)?.subjectName ?? '';
});
let detailAverage = $derived.by(() => {
if (!selectedSubjectId) return undefined;
return subjectAverage(selectedSubjectId);
});
// Color based on grade value (green ≥ 14, orange 10-14, red < 10, on /20 scale)
function gradeColor(value: number | null, scale: number): string {
if (value === null || scale <= 0) return '#6b7280';
const normalized = (value / scale) * 20;
if (normalized >= 14) return '#22c55e';
if (normalized >= 10) return '#f59e0b';
return '#ef4444';
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
}
function handleReveal(gradeId: string) {
revealGrade(gradeId);
}
function toggleDiscoverMode() {
const newMode = isDiscoverMode() ? 'immediate' : 'discover';
setRevealMode(newMode);
}
let seenTimerId: number | null = null;
$effect(() => {
loadData();
return () => {
if (seenTimerId !== null) window.clearTimeout(seenTimerId);
};
});
async function loadData() {
try {
isLoading = true;
error = null;
const [gradesData, averagesData] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
grades = gradesData;
averages = averagesData;
// Mark all loaded grades as seen (for "Nouveau" badge)
const ids = gradesData.map((g) => g.id);
// Delay marking to let the badge show briefly
seenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function openSubjectDetail(subjectId: string) {
selectedSubjectId = subjectId;
}
function closeDetail() {
selectedSubjectId = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeDetail();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedSubjectId) closeDetail();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="student-grades">
<header class="page-header">
<div class="header-top">
<h1>Mes notes</h1>
<label class="discover-toggle">
<input type="checkbox" checked={isDiscoverMode()} onchange={toggleDiscoverMode} />
<span class="toggle-label">Mode découverte</span>
</label>
</div>
{#if averages?.generalAverage != null}
<div class="general-average">
<span class="avg-label">Moyenne générale</span>
<span class="avg-value" style:color={gradeColor(averages.generalAverage, 20)}>
{averages.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
</header>
{#if error}
<div class="error-banner" role="alert">
<p>{error}</p>
<button onclick={() => (error = null)}>Fermer</button>
</div>
{/if}
{#if isLoading}
<SkeletonList items={5} message="Chargement des notes..." />
{:else if grades.length === 0}
<div class="empty-state">
<p>Aucune note publiée pour le moment.</p>
</div>
{:else}
<!-- Subject averages summary -->
{#if averages && averages.subjectAverages.length > 0}
<section class="averages-section">
<h2>Moyennes par matière</h2>
<div class="averages-grid">
{#each averages.subjectAverages as avg}
<button
class="average-card"
onclick={() => openSubjectDetail(avg.subjectId)}
>
<span class="avg-subject">{avg.subjectName ?? 'Matière'}</span>
<span class="avg-score" style:color={gradeColor(avg.average, 20)}>
{avg.average.toFixed(1)}
</span>
<span class="avg-count">{avg.gradeCount} note{avg.gradeCount > 1 ? 's' : ''}</span>
</button>
{/each}
</div>
</section>
{/if}
<!-- Recent grades -->
<section class="recent-section">
<h2>Dernières notes</h2>
<ul class="grades-list">
{#each grades as grade (grade.id)}
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
{@const isNew = isGradeNew(grade.id)}
<li>
<button class="grade-card grade-card-btn" onclick={() => discover ? handleReveal(grade.id) : openSubjectDetail(grade.subjectId)}>
<div class="grade-card-header">
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
<span class="grade-date">{formatDate(grade.evaluationDate)}</span>
{#if isNew}
<span class="badge-new">Nouveau</span>
{/if}
</div>
<div class="grade-card-body">
<span class="grade-eval-title">{grade.evaluationTitle}</span>
{#if discover}
<span class="grade-value grade-blur">??/{grade.gradeScale}</span>
{:else if grade.status === 'graded' && grade.value != null}
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
{grade.value}/{grade.gradeScale}
</span>
{:else if grade.status === 'absent'}
<span class="grade-status absent">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-status dispensed">Dispensé</span>
{/if}
</div>
{#if !discover && grade.classAverage != null}
<div class="grade-card-stats">
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
{#if grade.classMin != null && grade.classMax != null}
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
{/if}
</div>
{/if}
{#if !discover && grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
<div class="grade-card-meta">
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
</div>
{#if discover}
<span class="reveal-hint">Cliquer pour révéler</span>
{/if}
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<!-- Subject detail modal -->
{#if selectedSubjectId}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal-overlay" onclick={handleOverlayClick} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Détail matière {detailSubjectName}">
<div class="modal-header">
<h2>{detailSubjectName}</h2>
<button class="modal-close" onclick={closeDetail} aria-label="Fermer">&times;</button>
</div>
{#if detailAverage}
<div class="modal-average">
<span class="avg-label">Moyenne</span>
<span class="avg-value" style:color={gradeColor(detailAverage.average, 20)}>
{detailAverage.average.toFixed(1)}/20
</span>
</div>
{/if}
<ul class="detail-list">
{#each detailGrades as grade (grade.id)}
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
<li class="detail-item">
<div class="detail-header">
<span class="detail-title">{grade.evaluationTitle}</span>
<span class="detail-date">{formatDate(grade.evaluationDate)}</span>
</div>
<div class="detail-body">
{#if discover}
<button class="detail-reveal-btn" onclick={() => handleReveal(grade.id)}>
<span class="grade-blur">??/{grade.gradeScale}</span>
<span class="reveal-hint">Révéler</span>
</button>
{:else if grade.status === 'graded' && grade.value != null}
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
{grade.value}/{grade.gradeScale}
</span>
{:else if grade.status === 'absent'}
<span class="grade-status absent">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-status dispensed">Dispensé</span>
{/if}
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
</div>
{#if !discover && grade.classAverage != null}
<div class="grade-card-stats">
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
{#if grade.classMin != null && grade.classMax != null}
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
{/if}
</div>
{/if}
{#if !discover && grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.student-grades {
max-width: 64rem;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.discover-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: #6b7280;
}
.discover-toggle input {
accent-color: #8b5cf6;
}
.toggle-label {
user-select: none;
}
.general-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.75rem;
}
.avg-label {
font-size: 0.875rem;
color: #6b7280;
}
.avg-value {
font-size: 1.5rem;
font-weight: 700;
}
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
}
.error-banner p {
margin: 0;
}
.error-banner button {
background: none;
border: none;
color: #991b1b;
cursor: pointer;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
/* Averages section */
.averages-section h2,
.recent-section h2 {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.75rem;
}
.averages-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
.average-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition:
box-shadow 0.15s,
border-color 0.15s;
font: inherit;
}
.average-card:hover {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
.avg-subject {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
}
.avg-score {
font-size: 1.75rem;
font-weight: 700;
}
.avg-count {
font-size: 0.75rem;
color: #9ca3af;
}
/* Grade cards */
.grades-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.grade-card {
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
}
.grade-card-btn {
display: block;
width: 100%;
text-align: left;
cursor: pointer;
font: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.grade-card-btn:hover {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.grade-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.grade-subject {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
}
.grade-date {
font-size: 0.75rem;
color: #9ca3af;
margin-left: auto;
}
.badge-new {
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
background: #eff6ff;
color: #2563eb;
border-radius: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.grade-card-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.grade-eval-title {
font-weight: 500;
color: #1f2937;
}
.grade-value {
font-weight: 700;
font-size: 1.125rem;
white-space: nowrap;
}
.grade-status {
font-size: 0.875rem;
font-weight: 500;
}
.grade-status.absent {
color: #f59e0b;
}
.grade-status.dispensed {
color: #6b7280;
}
.grade-card-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.5rem;
}
.grade-appreciation {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #4b5563;
font-style: italic;
}
.grade-card-meta {
margin-top: 0.5rem;
}
.grade-coeff {
font-size: 0.75rem;
color: #9ca3af;
}
.grade-blur {
filter: blur(4px);
color: #9ca3af !important;
}
.detail-reveal-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f3f4f6;
border: 1px dashed #d1d5db;
border-radius: 0.375rem;
padding: 0.25rem 0.75rem;
cursor: pointer;
font: inherit;
transition: background 0.15s;
}
.detail-reveal-btn:hover {
background: #e5e7eb;
}
.reveal-hint {
display: block;
font-size: 0.625rem;
color: #8b5cf6;
text-align: right;
margin-top: 0.125rem;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.modal-close:hover {
color: #1f2937;
}
.modal-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.detail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.detail-title {
font-weight: 500;
color: #1f2937;
}
.detail-date {
font-size: 0.75rem;
color: #9ca3af;
}
.detail-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
</style>