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

@@ -2,8 +2,19 @@
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import type { StudentGrade } from '$lib/features/grades/api/studentGrades';
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import type { StudentAverages } from '$lib/features/grades/api/studentGrades';
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
import {
isGradeNew,
markGradesSeen,
isDiscoverMode,
setRevealMode,
isGradeRevealed,
revealGrade
} from '$lib/features/grades/stores/gradePreferences.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
@@ -36,6 +47,11 @@
let studentHomeworks = $state<StudentHomework[]>([]);
let homeworkLoading = $state(false);
// Grades widget state
let recentGrades = $state<StudentGrade[]>([]);
let studentAverages = $state<StudentAverages | null>(null);
let gradesLoading = $state(false);
let hwStatuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
@@ -88,6 +104,33 @@
}
}
let gradeSeenTimerId: number | null = null;
async function loadGrades() {
gradesLoading = true;
try {
const [all, avgs] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
recentGrades = all.slice(0, 5);
studentAverages = avgs;
const ids = all.map((g) => g.id);
gradeSeenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
} catch {
// Silently fail on dashboard widget
} finally {
gradesLoading = false;
}
}
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';
}
// Homework detail modal
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
@@ -105,17 +148,51 @@
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeHomeworkDetail();
if (e.target === e.currentTarget) {
closeHomeworkDetail();
closeGradeDetail();
}
}
function handleModalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeHomeworkDetail();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
closeHomeworkDetail();
closeGradeDetail();
}
}
// Grade detail modal
let selectedGrade = $state<StudentGrade | null>(null);
function openGradeDetail(grade: StudentGrade) {
selectedGrade = grade;
}
function closeGradeDetail() {
selectedGrade = null;
}
function toggleDiscoverMode() {
setRevealMode(isDiscoverMode() ? 'immediate' : 'discover');
}
function handleReveal(gradeId: string) {
revealGrade(gradeId);
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
}
$effect(() => {
if (!isEleve) return;
void loadTodaySchedule();
void loadHomeworks();
void loadGrades();
return () => {
if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId);
};
});
</script>
@@ -179,11 +256,65 @@
<!-- Notes Section -->
<DashboardSection
title="Mes notes"
subtitle={hasRealData ? "Dernières notes" : undefined}
isPlaceholder={!hasRealData}
subtitle={isEleve ? "Dernières notes" : (hasRealData ? "Dernières notes" : undefined)}
isPlaceholder={!isEleve && !hasRealData}
placeholderMessage={isMinor ? "Tes notes apparaîtront ici" : "Vos notes apparaîtront ici"}
>
{#if hasRealData}
{#if isEleve}
{#if gradesLoading}
<SkeletonList items={3} message="Chargement des notes..." />
{:else if recentGrades.length === 0}
<p class="empty-grades">Aucune note publiée</p>
{:else}
<div class="widget-grades-header">
{#if studentAverages?.generalAverage != null}
<div class="widget-general-avg">
<span class="widget-avg-label">Moyenne générale</span>
<span class="widget-avg-value" style:color={gradeColor(studentAverages.generalAverage, 20)}>
{studentAverages.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
<label class="widget-discover-toggle">
<input type="checkbox" checked={isDiscoverMode()} onchange={toggleDiscoverMode} />
<span>Mode découverte</span>
</label>
</div>
<ul class="grades-list">
{#each recentGrades as grade}
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
<li>
<button class="grade-item grade-item-btn" onclick={() => discover ? handleReveal(grade.id) : openGradeDetail(grade)}>
<div class="grade-header">
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
{#if isGradeNew(grade.id)}
<span class="grade-badge-new">Nouveau</span>
{/if}
{#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-value" style:color="#f59e0b">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-value" style:color="#6b7280">Dispensé</span>
{/if}
</div>
<span class="grade-eval">{grade.evaluationTitle}</span>
{#if discover}
<span class="grade-reveal-hint">Cliquer pour révéler</span>
{/if}
</button>
</li>
{/each}
</ul>
<a href="/dashboard/student-grades" class="view-all-link">
Voir toutes les notes →
</a>
{/if}
{:else if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des notes..." />
{:else}
@@ -258,9 +389,11 @@
</div>
</div>
<svelte:window onkeydown={handleKeydown} />
{#if selectedHomeworkDetail}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
<div class="homework-modal-overlay" onclick={handleOverlayClick} role="presentation">
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">&times;</button>
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} />
@@ -268,6 +401,61 @@
</div>
{/if}
{#if selectedGrade}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} role="presentation">
<div class="grade-detail-modal" role="dialog" aria-modal="true" aria-label="Détail de la note">
<button class="homework-modal-close" onclick={closeGradeDetail} aria-label="Fermer">&times;</button>
<div class="grade-detail-header">
<span class="grade-detail-subject">{selectedGrade.subjectName ?? 'Matière'}</span>
<span class="grade-detail-date">{formatDate(selectedGrade.evaluationDate)}</span>
</div>
<h3 class="grade-detail-title">{selectedGrade.evaluationTitle}</h3>
<div class="grade-detail-score">
{#if selectedGrade.status === 'graded' && selectedGrade.value != null}
<span class="grade-detail-value" style:color={gradeColor(selectedGrade.value, selectedGrade.gradeScale)}>
{selectedGrade.value}/{selectedGrade.gradeScale}
</span>
{:else if selectedGrade.status === 'absent'}
<span class="grade-detail-value" style:color="#f59e0b">Absent</span>
{:else if selectedGrade.status === 'dispensed'}
<span class="grade-detail-value" style:color="#6b7280">Dispensé</span>
{/if}
<span class="grade-detail-coeff">Coeff. {selectedGrade.coefficient}</span>
</div>
{#if selectedGrade.appreciation}
<div class="grade-detail-appreciation">
<span class="grade-detail-label">Appréciation</span>
<p>{selectedGrade.appreciation}</p>
</div>
{/if}
{#if selectedGrade.classAverage != null}
<div class="grade-detail-stats">
<span class="grade-detail-label">Statistiques de la classe</span>
<div class="grade-detail-stats-grid">
<div class="stat-item">
<span class="stat-value">{selectedGrade.classAverage.toFixed(1)}</span>
<span class="stat-label">Moyenne</span>
</div>
{#if selectedGrade.classMin != null}
<div class="stat-item">
<span class="stat-value">{selectedGrade.classMin.toFixed(1)}</span>
<span class="stat-label">Min</span>
</div>
{/if}
{#if selectedGrade.classMax != null}
<div class="stat-item">
<span class="stat-value">{selectedGrade.classMax.toFixed(1)}</span>
<span class="stat-label">Max</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
<style>
.dashboard-student {
display: flex;
@@ -383,6 +571,20 @@
border-radius: 0.5rem;
}
.grade-item-btn {
display: block;
width: 100%;
border: none;
text-align: left;
cursor: pointer;
font: inherit;
transition: background 0.15s;
}
.grade-item-btn:hover {
background: #f3f4f6;
}
.grade-header {
display: flex;
justify-content: space-between;
@@ -406,6 +608,188 @@
color: #6b7280;
}
.grade-badge-new {
font-size: 0.5625rem;
padding: 0.0625rem 0.375rem;
background: #eff6ff;
color: #2563eb;
border-radius: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.empty-grades {
margin: 0;
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.widget-grades-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.widget-general-avg {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
}
.widget-avg-label {
font-size: 0.75rem;
color: #6b7280;
}
.widget-avg-value {
font-size: 1rem;
font-weight: 700;
}
.widget-discover-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
font-size: 0.75rem;
color: #6b7280;
align-self: flex-end;
}
.widget-discover-toggle input {
accent-color: #8b5cf6;
}
.grade-blur {
filter: blur(4px);
color: #9ca3af !important;
}
.grade-reveal-hint {
display: block;
font-size: 0.625rem;
color: #8b5cf6;
text-align: right;
margin-top: 0.125rem;
}
/* Grade detail modal */
.grade-detail-modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 28rem;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.grade-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
padding-right: 2rem;
}
.grade-detail-subject {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
}
.grade-detail-date {
font-size: 0.75rem;
color: #9ca3af;
}
.grade-detail-title {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.grade-detail-score {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.grade-detail-value {
font-size: 1.5rem;
font-weight: 700;
}
.grade-detail-coeff {
font-size: 0.75rem;
color: #9ca3af;
}
.grade-detail-appreciation {
margin-bottom: 1rem;
}
.grade-detail-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
margin-bottom: 0.375rem;
}
.grade-detail-appreciation p {
margin: 0;
font-size: 0.875rem;
color: #4b5563;
font-style: italic;
line-height: 1.5;
}
.grade-detail-stats {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.grade-detail-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
text-align: center;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.stat-value {
font-size: 1.125rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.625rem;
color: #9ca3af;
text-transform: uppercase;
}
/* Homework List */
.homework-list {
list-style: none;