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,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">×</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">×</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;
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -109,6 +109,7 @@
|
||||
{/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}
|
||||
@@ -164,6 +165,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>
|
||||
|
||||
695
frontend/src/routes/dashboard/student-grades/+page.svelte
Normal file
695
frontend/src/routes/dashboard/student-grades/+page.svelte
Normal 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">×</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>
|
||||
Reference in New Issue
Block a user