feat: Permettre à l'élève de consulter ses notes et moyennes
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:
@@ -2,8 +2,12 @@
|
||||
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 } 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 +40,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 +97,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);
|
||||
|
||||
@@ -116,6 +152,10 @@
|
||||
if (!isEleve) return;
|
||||
void loadTodaySchedule();
|
||||
void loadHomeworks();
|
||||
void loadGrades();
|
||||
return () => {
|
||||
if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -179,11 +219,51 @@
|
||||
<!-- 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}
|
||||
{#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}
|
||||
<ul class="grades-list">
|
||||
{#each recentGrades as grade}
|
||||
<li class="grade-item">
|
||||
<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 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>
|
||||
</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}
|
||||
@@ -406,6 +486,45 @@
|
||||
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-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;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.widget-avg-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.widget-avg-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Homework List */
|
||||
.homework-list {
|
||||
list-style: none;
|
||||
|
||||
82
frontend/src/lib/features/grades/api/studentGrades.ts
Normal file
82
frontend/src/lib/features/grades/api/studentGrades.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
export interface StudentGrade {
|
||||
id: string;
|
||||
evaluationId: string;
|
||||
evaluationTitle: string;
|
||||
evaluationDate: string;
|
||||
gradeScale: number;
|
||||
coefficient: number;
|
||||
subjectId: string;
|
||||
subjectName: string | null;
|
||||
value: number | null;
|
||||
status: string;
|
||||
appreciation: string | null;
|
||||
publishedAt: string | null;
|
||||
classAverage: number | null;
|
||||
classMin: number | null;
|
||||
classMax: number | null;
|
||||
}
|
||||
|
||||
export interface SubjectAverage {
|
||||
subjectId: string;
|
||||
subjectName: string | null;
|
||||
average: number;
|
||||
gradeCount: number;
|
||||
}
|
||||
|
||||
export interface StudentAverages {
|
||||
studentId: string;
|
||||
periodId: string | null;
|
||||
subjectAverages: SubjectAverage[];
|
||||
generalAverage: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère toutes les notes publiées de l'élève connecté.
|
||||
*/
|
||||
export async function fetchMyGrades(): Promise<StudentGrade[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/grades`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
// API Platform returns hydra:member or raw array
|
||||
return json['hydra:member'] ?? json.member ?? json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les notes de l'élève pour une matière.
|
||||
*/
|
||||
export async function fetchMyGradesBySubject(subjectId: string): Promise<StudentGrade[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(
|
||||
`${apiUrl}/me/grades/subject/${encodeURIComponent(subjectId)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json['hydra:member'] ?? json.member ?? json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les moyennes de l'élève connecté.
|
||||
*/
|
||||
export async function fetchMyAverages(periodId?: string): Promise<StudentAverages> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : '';
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/averages${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement des moyennes (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const PREFS_KEY = 'classeo_grade_preferences';
|
||||
const SEEN_KEY = 'classeo_grades_seen';
|
||||
const REVEALED_KEY = 'classeo_grades_revealed';
|
||||
|
||||
export type RevealMode = 'immediate' | 'discover';
|
||||
|
||||
interface GradePreferences {
|
||||
revealMode: RevealMode;
|
||||
}
|
||||
|
||||
// Reactive state
|
||||
let revealMode = $state<RevealMode>('immediate');
|
||||
let seenGradeIds = $state<Set<string>>(new Set());
|
||||
let revealedGradeIds = $state<Set<string>>(new Set());
|
||||
|
||||
// Load from localStorage on init
|
||||
if (browser) {
|
||||
try {
|
||||
const stored = localStorage.getItem(PREFS_KEY);
|
||||
if (stored) {
|
||||
const prefs = JSON.parse(stored) as GradePreferences;
|
||||
revealMode = prefs.revealMode ?? 'immediate';
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(SEEN_KEY);
|
||||
if (stored) {
|
||||
seenGradeIds = new Set(JSON.parse(stored) as string[]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(REVEALED_KEY);
|
||||
if (stored) {
|
||||
revealedGradeIds = new Set(JSON.parse(stored) as string[]);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(PREFS_KEY, JSON.stringify({ revealMode }));
|
||||
} catch {
|
||||
// QuotaExceededError — preference still active in memory
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeen(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify([...seenGradeIds]));
|
||||
} catch {
|
||||
// QuotaExceededError
|
||||
}
|
||||
}
|
||||
|
||||
function saveRevealed(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
localStorage.setItem(REVEALED_KEY, JSON.stringify([...revealedGradeIds]));
|
||||
} catch {
|
||||
// QuotaExceededError
|
||||
}
|
||||
}
|
||||
|
||||
export function getRevealMode(): RevealMode {
|
||||
return revealMode;
|
||||
}
|
||||
|
||||
export function setRevealMode(mode: RevealMode): void {
|
||||
revealMode = mode;
|
||||
savePrefs();
|
||||
}
|
||||
|
||||
export function isGradeNew(gradeId: string): boolean {
|
||||
return !seenGradeIds.has(gradeId);
|
||||
}
|
||||
|
||||
export function markGradesSeen(gradeIds: string[]): void {
|
||||
seenGradeIds = new Set([...seenGradeIds, ...gradeIds]);
|
||||
saveSeen();
|
||||
}
|
||||
|
||||
export function isGradeRevealed(gradeId: string): boolean {
|
||||
return revealedGradeIds.has(gradeId);
|
||||
}
|
||||
|
||||
export function revealGrade(gradeId: string): void {
|
||||
revealedGradeIds = new Set([...revealedGradeIds, gradeId]);
|
||||
saveRevealed();
|
||||
}
|
||||
|
||||
export function isDiscoverMode(): boolean {
|
||||
return revealMode === 'discover';
|
||||
}
|
||||
Reference in New Issue
Block a user