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;

View File

@@ -0,0 +1,765 @@
<script lang="ts">
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import type { StudentGrade } from '$lib/features/grades/api/studentGrades';
import {
fetchChildGrades,
fetchChildrenGradesSummary,
type ChildGrades,
type ChildGradesSummary
} from '$lib/features/grades/api/parentGrades';
import { isGradeNew, markGradesSeen } from '$lib/features/grades/stores/gradePreferences.svelte';
let selectedChildId: string | null = $state(null);
let childrenGrades: ChildGrades | null = $state(null);
let summaries: ChildGradesSummary[] = $state([]);
let isLoading = $state(false);
let error: string | null = $state(null);
let selectedSubjectId: string | null = $state(null);
let seenTimerId: number | null = $state(null);
let loadGeneration = $state(0);
const RECENT_GRADES_LIMIT = 5;
// Group grades by subject for the selected child
let subjectGroups = $derived.by(() => {
if (!childrenGrades) return new Map<string, { subjectName: string; grades: StudentGrade[] }>();
const map = new Map<string, { subjectName: string; grades: StudentGrade[] }>();
for (const g of childrenGrades.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;
});
// Most recent grades (capped)
let recentGrades = $derived.by(() =>
childrenGrades ? childrenGrades.grades.slice(0, RECENT_GRADES_LIMIT) : []
);
let hasMoreGrades = $derived.by(() =>
childrenGrades ? childrenGrades.grades.length > RECENT_GRADES_LIMIT : false
);
let totalGradesCount = $derived.by(() => childrenGrades?.grades.length ?? 0);
// Current child summary (averages)
let currentSummary = $derived.by(() => {
if (!selectedChildId) return null;
return summaries.find((s) => s.childId === selectedChildId) ?? null;
});
// Filtered grades for subject detail modal
let detailGrades = $derived.by(() => {
if (!selectedSubjectId || !childrenGrades) return [];
return childrenGrades.grades.filter((g) => g.subjectId === selectedSubjectId);
});
let detailSubjectName = $derived.by(() => {
if (!selectedSubjectId) return '';
return subjectGroups.get(selectedSubjectId)?.subjectName ?? '';
});
let detailAverage = $derived.by(() => {
if (!selectedSubjectId || !currentSummary) return undefined;
return currentSummary.subjectAverages.find((a) => a.subjectId === selectedSubjectId);
});
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 cancelSeenTimer() {
if (seenTimerId !== null) {
window.clearTimeout(seenTimerId);
seenTimerId = null;
}
}
function onChildSelected(childId: string | null) {
selectedChildId = childId;
selectedSubjectId = null;
cancelSeenTimer();
if (childId) {
loadChildGrades(childId);
} else {
childrenGrades = null;
loadSummaries();
}
}
async function loadSummaries() {
try {
isLoading = true;
error = null;
summaries = await fetchChildrenGradesSummary();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function loadChildGrades(childId: string) {
cancelSeenTimer();
const generation = ++loadGeneration;
try {
isLoading = true;
error = null;
// Grades are primary — summary failure must not block them
const gradesData = await fetchChildGrades(childId);
if (generation !== loadGeneration) return;
childrenGrades = gradesData;
const ids = gradesData.grades.map((g) => g.id);
if (ids.length > 0) {
seenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
}
// Summary is secondary — fetch independently
fetchChildrenGradesSummary()
.then((data) => {
if (generation === loadGeneration) summaries = data;
})
.catch(() => {
/* summary failure is non-blocking */
});
} catch (e) {
if (generation !== loadGeneration) return;
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
if (generation === loadGeneration) {
isLoading = false;
}
}
}
// Load summaries on mount (for multi-children view before any child is selected)
$effect(() => {
loadSummaries();
return () => cancelSeenTimer();
});
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="parent-grades">
<ChildSelector {onChildSelected} />
{#if error}
<div class="error-banner" role="alert">
<p>{error}</p>
<button onclick={() => (error = null)}>Fermer</button>
</div>
{/if}
{#if !selectedChildId}
<!-- Multi-children summary view -->
{#if summaries.length > 0}
<section class="summary-section">
{#each summaries as child}
<div class="child-summary-card">
<h3>{child.firstName} {child.lastName}</h3>
{#if child.generalAverage != null}
<div class="general-average">
<span class="avg-label">Moyenne générale</span>
<span class="avg-value" style:color={gradeColor(child.generalAverage, 20)}>
{child.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
{#if child.subjectAverages.length > 0}
<div class="averages-grid">
{#each child.subjectAverages as avg}
<div class="average-card">
<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
>
</div>
{/each}
</div>
{:else}
<p class="no-data">Aucune moyenne disponible.</p>
{/if}
</div>
{/each}
</section>
{:else if !isLoading}
<div class="empty-state">
<p>Sélectionnez un enfant pour voir ses notes.</p>
</div>
{/if}
{:else if isLoading}
<SkeletonList items={5} message="Chargement des notes..." />
{:else if childrenGrades && childrenGrades.grades.length === 0}
<div class="empty-state">
<p>
Aucune note publiée pour {childrenGrades.firstName} pour le moment.
</p>
</div>
{:else if childrenGrades}
<!-- General average -->
{#if currentSummary?.generalAverage != null}
<div class="general-average">
<span class="avg-label">Moyenne générale de {childrenGrades.firstName}</span>
<span class="avg-value" style:color={gradeColor(currentSummary.generalAverage, 20)}>
{currentSummary.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
<!-- Subject averages -->
{#if currentSummary && currentSummary.subjectAverages.length > 0}
<section class="averages-section">
<h2>Moyennes par matière</h2>
<div class="averages-grid">
{#each currentSummary.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 list (capped) -->
<section class="recent-section">
<h2>Dernières notes</h2>
<ul class="grades-list">
{#each recentGrades as grade (grade.id)}
{@const isNew = isGradeNew(grade.id)}
<li>
<button
class="grade-card grade-card-btn"
onclick={() => 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 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 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 grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
<div class="grade-card-meta">
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
</div>
</button>
</li>
{/each}
</ul>
{#if hasMoreGrades}
<p class="more-grades-hint">
{totalGradesCount - RECENT_GRADES_LIMIT} notes supplémentaires — cliquez sur une matière ci-dessus pour voir le détail.
</p>
{/if}
</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)}
<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 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 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 grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.parent-grades {
max-width: 64rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.error-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
font-size: 0.875rem;
}
.error-banner p {
margin: 0;
}
.error-banner button {
background: none;
border: none;
color: #991b1b;
cursor: pointer;
font-weight: 600;
}
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: #6b7280;
font-size: 0.875rem;
}
/* Summary for multi-children */
.summary-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.child-summary-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.child-summary-card h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.no-data {
color: #9ca3af;
font-size: 0.875rem;
margin: 0;
}
/* General average */
.general-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.avg-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
}
.avg-value {
font-size: 1.25rem;
font-weight: 700;
}
/* Averages grid */
.averages-section h2,
.recent-section h2 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 0.75rem;
}
.more-grades-hint {
text-align: center;
color: #6b7280;
font-size: 0.875rem;
margin: 0.75rem 0 0;
padding: 0.5rem;
}
.averages-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem;
}
.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;
transition:
box-shadow 0.15s,
border-color 0.15s;
}
button.average-card {
cursor: pointer;
}
button.average-card:hover {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
}
.avg-subject {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-align: center;
}
.avg-score {
font-size: 1.5rem;
font-weight: 700;
}
.avg-count {
font-size: 0.75rem;
color: #9ca3af;
}
/* Grades list */
.grades-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.grade-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: border-color 0.15s;
}
.grade-card-btn {
width: 100%;
text-align: left;
cursor: pointer;
}
.grade-card-btn:hover {
border-color: #3b82f6;
}
.grade-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.grade-subject {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.grade-date {
font-size: 0.75rem;
color: #9ca3af;
margin-left: auto;
}
.badge-new {
padding: 0.125rem 0.5rem;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
background: #dbeafe;
color: #1d4ed8;
border-radius: 9999px;
}
.grade-card-body {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.grade-eval-title {
font-size: 0.875rem;
color: #4b5563;
}
.grade-value {
font-size: 1.125rem;
font-weight: 700;
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: #6b7280;
}
.grade-appreciation {
font-size: 0.8125rem;
color: #4b5563;
font-style: italic;
margin: 0;
}
.grade-card-meta {
display: flex;
gap: 0.5rem;
}
.grade-coeff {
font-size: 0.75rem;
color: #9ca3af;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: white;
border-radius: 0.75rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 36rem;
max-height: 85vh;
overflow-y: auto;
padding: 1.5rem;
}
.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: #9ca3af;
cursor: pointer;
padding: 0.25rem;
line-height: 1;
}
.modal-close:hover {
color: #374151;
}
.modal-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.detail-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-title {
font-size: 0.875rem;
font-weight: 500;
color: #1f2937;
}
.detail-date {
font-size: 0.75rem;
color: #9ca3af;
}
.detail-body {
display: flex;
align-items: center;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,74 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
import type { StudentGrade, SubjectAverage } from './studentGrades';
export interface ChildGrades {
childId: string;
firstName: string;
lastName: string;
grades: StudentGrade[];
}
export interface ChildGradesSummary {
childId: string;
firstName: string;
lastName: string;
periodId: string | null;
subjectAverages: SubjectAverage[];
generalAverage: number | null;
}
/**
* Récupère les notes d'un enfant spécifique.
*/
export async function fetchChildGrades(childId: string): Promise<ChildGrades> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/me/children/${encodeURIComponent(childId)}/grades`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Récupère les notes d'un enfant filtrées par matière.
*/
export async function fetchChildGradesBySubject(
childId: string,
subjectId: string
): Promise<ChildGrades> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/me/children/${encodeURIComponent(childId)}/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.data;
}
/**
* Récupère le résumé des moyennes de tous les enfants.
*/
export async function fetchChildrenGradesSummary(
periodId?: string
): Promise<ChildGradesSummary[]> {
const apiUrl = getApiBaseUrl();
const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/children/grades/summary${params}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des moyennes (${response.status})`);
}
const json = await response.json();
return json.data ?? [];
}

View File

@@ -0,0 +1,83 @@
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;
subjectColor: 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();
}

View File

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

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>