feat: Calculer automatiquement les moyennes après chaque saisie de notes
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

Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
This commit is contained in:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit b7dc27f2a5
786 changed files with 118783 additions and 316 deletions

View File

@@ -0,0 +1,187 @@
<script lang="ts">
interface CompetencyProgress {
competencyId: string;
competencyCode: string;
competencyName: string;
currentLevelCode: string | null;
currentLevelName: string | null;
history: Array<{ date: string; levelCode: string; evaluationTitle: string }>;
}
interface CompetencyLevel {
code: string;
name: string;
color: string | null;
sortOrder: number;
}
interface Props {
progress: CompetencyProgress[];
levels: CompetencyLevel[];
}
let { progress, levels }: Props = $props();
function getBarWidth(levelCode: string | null): number {
if (!levelCode || levels.length === 0) return 0;
const idx = levels.findIndex((l) => l.code === levelCode);
if (idx === -1) return 0;
return ((idx + 1) / levels.length) * 100;
}
function getBarColor(levelCode: string | null): string {
if (!levelCode) return '#e2e8f0';
const level = levels.find((l) => l.code === levelCode);
return level?.color ?? '#94a3b8';
}
</script>
<div class="competency-progress-chart">
{#if progress.length === 0}
<p class="empty-message">Aucune compétence évaluée pour le moment.</p>
{:else}
<div class="bars-container">
{#each progress as item (item.competencyId)}
<div class="bar-row">
<div class="bar-label" title={item.competencyName}>
<span class="bar-code">{item.competencyCode}</span>
<span class="bar-name">{item.competencyName}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
style="width: {getBarWidth(item.currentLevelCode)}%; background-color: {getBarColor(item.currentLevelCode)};"
title={item.currentLevelName ?? 'Non évalué'}
></div>
<!-- Level markers -->
{#each levels as level, i (level.code)}
<div
class="bar-marker"
style="left: {((i + 1) / levels.length) * 100}%"
title={level.name}
></div>
{/each}
</div>
<div class="bar-value" style="color: {getBarColor(item.currentLevelCode)}">
{item.currentLevelName ?? '-'}
</div>
</div>
{/each}
</div>
<!-- Level scale -->
<div class="level-scale">
{#each levels as level (level.code)}
<span class="scale-item" style="--level-color: {level.color}">
<span class="scale-dot" style="background-color: {level.color}"></span>
{level.name}
</span>
{/each}
</div>
{/if}
</div>
<style>
.competency-progress-chart {
width: 100%;
}
.empty-message {
text-align: center;
color: var(--color-text-secondary, #64748b);
padding: 2rem;
}
.bars-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bar-row {
display: grid;
grid-template-columns: 200px 1fr 120px;
align-items: center;
gap: 0.75rem;
}
@media (max-width: 640px) {
.bar-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}
.bar-label {
overflow: hidden;
}
.bar-code {
font-weight: 700;
font-size: 0.8rem;
color: var(--color-text, #1e293b);
}
.bar-name {
display: block;
font-size: 0.75rem;
color: var(--color-text-secondary, #64748b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
position: relative;
height: 1.25rem;
background: var(--color-surface-alt, #f1f5f9);
border-radius: 0.625rem;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 0.625rem;
transition: width 0.3s ease;
}
.bar-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--color-border, #e2e8f0);
}
.bar-value {
font-size: 0.8rem;
font-weight: 600;
text-align: right;
}
.level-scale {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e2e8f0);
flex-wrap: wrap;
}
.scale-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8rem;
color: var(--color-text-secondary, #64748b);
}
.scale-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,64 @@
const DB_NAME = 'classeo-appreciations';
const DB_VERSION = 1;
const STORE_NAME = 'pending-appreciations';
interface PendingAppreciation {
evaluationId: string;
studentId: string;
appreciation: string | null;
savedAt: number;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: ['evaluationId', 'studentId'],
});
store.createIndex('byEvaluation', 'evaluationId', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function savePendingAppreciation(appreciation: PendingAppreciation): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(appreciation);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getPendingAppreciations(evaluationId: string): Promise<PendingAppreciation[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.objectStore(STORE_NAME).index('byEvaluation');
const request = index.getAll(evaluationId);
request.onsuccess = () => resolve(request.result as PendingAppreciation[]);
request.onerror = () => reject(request.error);
});
}
export async function clearPendingAppreciations(evaluationId: string): Promise<void> {
const db = await openDb();
const pending = await getPendingAppreciations(evaluationId);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const item of pending) {
store.delete([item.evaluationId, item.studentId]);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}

View File

@@ -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-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>
@@ -163,6 +164,9 @@
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
Mon emploi du temps
</a>
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
Compétences
</a>
{/if}
{#if isParent}
<a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>

View File

@@ -0,0 +1,375 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
import CompetencyProgressBar from '$lib/components/organisms/CompetencyProgress/CompetencyProgressBar.svelte';
interface CompetencyLevel {
code: string;
name: string;
color: string | null;
sortOrder: number;
}
interface CompetencyProgress {
competencyId: string;
competencyCode: string;
competencyName: string;
currentLevelCode: string | null;
currentLevelName: string | null;
history: Array<{ date: string; levelCode: string; evaluationTitle: string }>;
}
let levels: CompetencyLevel[] = $state([]);
let progress: CompetencyProgress[] = $state([]);
let isLoading = $state(true);
let error: string | null = $state(null);
let selectedCompetencyId: string | null = $state(null);
let selectedHistory = $derived.by(() => {
if (!selectedCompetencyId) return null;
return progress.find((p) => p.competencyId === selectedCompetencyId) ?? null;
});
$effect(() => {
loadAll();
});
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const userId = await getAuthenticatedUserId();
if (!userId) throw new Error('Non authentifié');
const [levelsRes, progressRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/competency-levels`),
authenticatedFetch(`${apiUrl}/students/${userId}/competency-progress`),
]);
if (!levelsRes.ok) throw new Error('Erreur lors du chargement des niveaux');
if (!progressRes.ok) throw new Error('Erreur lors du chargement de la progression');
const levelsData = await levelsRes.json();
levels = (levelsData['hydra:member'] ?? levelsData['member'] ?? (Array.isArray(levelsData) ? levelsData : [])) as CompetencyLevel[];
const progressData = await progressRes.json();
progress = (progressData['hydra:member'] ?? progressData['member'] ?? (Array.isArray(progressData) ? progressData : [])) as CompetencyProgress[];
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function getLevelColor(levelCode: string): string {
const level = levels.find((l) => l.code === levelCode);
return level?.color ?? '#94a3b8';
}
function getLevelName(levelCode: string): string {
const level = levels.find((l) => l.code === levelCode);
return level?.name ?? levelCode;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(dateStr));
} catch {
return dateStr;
}
}
</script>
<svelte:head>
<title>Mes compétences - Classeo</title>
</svelte:head>
<div class="competencies-page">
<h1>Mes compétences</h1>
{#if error}
<div class="error-banner" role="alert">
<p>{error}</p>
<button onclick={() => (error = null)}>Fermer</button>
</div>
{/if}
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement de vos compétences...</p>
</div>
{:else if progress.length === 0}
<div class="empty-state">
<h2>Aucune compétence évaluée</h2>
<p>Vos résultats de compétences apparaîtront ici dès qu'une évaluation par compétences sera complétée.</p>
</div>
{:else}
<section class="synthesis-section">
<h2>Synthèse</h2>
<CompetencyProgressBar {progress} {levels} />
</section>
<section class="detail-section">
<h2>Détail par compétence</h2>
<p class="detail-hint">Cliquez sur une compétence pour voir l'historique de progression.</p>
<div class="competency-cards">
{#each progress as item (item.competencyId)}
<button
class="competency-card"
class:selected={selectedCompetencyId === item.competencyId}
onclick={() => {
selectedCompetencyId = selectedCompetencyId === item.competencyId ? null : item.competencyId;
}}
>
<div class="card-header">
<span class="card-code">{item.competencyCode}</span>
{#if item.currentLevelCode}
<span
class="card-badge"
style="background-color: {getLevelColor(item.currentLevelCode)}"
>
{item.currentLevelName}
</span>
{:else}
<span class="card-badge card-badge-empty">Non évalué</span>
{/if}
</div>
<div class="card-name">{item.competencyName}</div>
{#if item.history.length > 1}
<div class="card-evals">{item.history.length} évaluations</div>
{/if}
</button>
{/each}
</div>
{#if selectedHistory}
<div class="history-panel">
<h3>Historique : {selectedHistory.competencyCode} - {selectedHistory.competencyName}</h3>
<div class="history-timeline">
{#each selectedHistory.history as entry, i (i)}
<div class="timeline-entry">
<div
class="timeline-dot"
style="background-color: {getLevelColor(entry.levelCode)}"
></div>
<div class="timeline-content">
<span class="timeline-level" style="color: {getLevelColor(entry.levelCode)}">
{getLevelName(entry.levelCode)}
</span>
<span class="timeline-eval">{entry.evaluationTitle}</span>
<span class="timeline-date">{formatDate(entry.date)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
</div>
<style>
.competencies-page {
padding: 1.5rem;
max-width: 900px;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
}
h2 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
}
.error-banner {
background: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-banner p { margin: 0; }
.error-banner button {
background: none;
border: none;
color: var(--color-error, #dc2626);
cursor: pointer;
font-weight: 600;
}
.loading-state, .empty-state {
text-align: center;
padding: 3rem;
color: var(--color-text-secondary, #64748b);
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e2e8f0);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
.synthesis-section {
margin-bottom: 2rem;
padding: 1.25rem;
background: var(--color-surface, #fff);
border-radius: 0.75rem;
border: 1px solid var(--color-border, #e2e8f0);
}
.detail-section {
margin-bottom: 2rem;
}
.detail-hint {
font-size: 0.875rem;
color: var(--color-text-secondary, #64748b);
margin-bottom: 1rem;
}
.competency-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.competency-card {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
width: 100%;
font-family: inherit;
font-size: inherit;
}
.competency-card:hover {
border-color: var(--color-primary, #3b82f6);
}
.competency-card.selected {
border-color: var(--color-primary, #3b82f6);
box-shadow: 0 0 0 1px var(--color-primary, #3b82f6);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.card-code {
font-weight: 700;
font-size: 0.875rem;
}
.card-badge {
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.7rem;
font-weight: 600;
color: #fff;
}
.card-badge-empty {
background: var(--color-text-secondary, #94a3b8);
}
.card-name {
font-size: 0.8rem;
color: var(--color-text-secondary, #64748b);
}
.card-evals {
font-size: 0.7rem;
color: var(--color-text-tertiary, #a1a1aa);
margin-top: 0.375rem;
}
.history-panel {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.75rem;
padding: 1.25rem;
}
.history-panel h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
}
.history-timeline {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-left: 1rem;
border-left: 2px solid var(--color-border, #e2e8f0);
}
.timeline-entry {
display: flex;
align-items: flex-start;
gap: 0.75rem;
position: relative;
}
.timeline-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
margin-top: 0.25rem;
margin-left: -1.375rem;
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.timeline-level {
font-weight: 600;
font-size: 0.875rem;
}
.timeline-eval {
font-size: 0.8rem;
color: var(--color-text, #1e293b);
}
.timeline-date {
font-size: 0.75rem;
color: var(--color-text-secondary, #64748b);
}
</style>

View File

@@ -57,6 +57,14 @@
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Competency data
interface CompetencyItem {
id: string;
code: string;
name: string;
}
let availableCompetencies = $state<CompetencyItem[]>([]);
// Create modal
let showCreateModal = $state(false);
let newClassId = $state('');
@@ -66,6 +74,8 @@
let newEvaluationDate = $state('');
let newGradeScale = $state(20);
let newCoefficient = $state(1.0);
let newIsCompetency = $state(false);
let newSelectedCompetencyIds = $state<string[]>([]);
let isSubmitting = $state(false);
// Edit modal
@@ -135,9 +145,10 @@
error = null;
const apiUrl = getApiBaseUrl();
const [classesRes, subjectsRes] = await Promise.all([
const [classesRes, subjectsRes, competenciesRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/competencies`).catch(() => null),
loadAssignments().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations';
}),
@@ -158,6 +169,11 @@
classes = extractCollection<SchoolClass>(classesData);
subjects = extractCollection<Subject>(subjectsData);
if (competenciesRes && competenciesRes.ok) {
const compData = await competenciesRes.json();
availableCompetencies = extractCollection<CompetencyItem>(compData);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
@@ -264,14 +280,19 @@
newEvaluationDate = '';
newGradeScale = 20;
newCoefficient = 1.0;
newIsCompetency = false;
newSelectedCompetencyIds = [];
}
function closeCreateModal() {
showCreateModal = false;
newIsCompetency = false;
newSelectedCompetencyIds = [];
}
async function handleCreate() {
if (!newClassId || !newSubjectId || !newTitle.trim() || !newEvaluationDate) return;
if (newIsCompetency && newSelectedCompetencyIds.length === 0) return;
try {
isSubmitting = true;
@@ -286,8 +307,8 @@
title: newTitle.trim(),
description: newDescription.trim() || null,
evaluationDate: newEvaluationDate,
gradeScale: newGradeScale,
coefficient: newCoefficient,
gradeScale: newIsCompetency ? 1 : newGradeScale,
coefficient: newIsCompetency ? 1.0 : newCoefficient,
}),
});
@@ -296,6 +317,21 @@
throw new Error((data as Record<string, string> | null)?.['message'] ?? 'Erreur lors de la création');
}
// Link competencies if competency evaluation
if (newIsCompetency && newSelectedCompetencyIds.length > 0) {
const evalData = await response.json();
const evalId = (evalData as Record<string, string>)['id'];
const linkRes = await authenticatedFetch(`${apiUrl}/evaluations/${evalId}/competencies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ competencyIds: newSelectedCompetencyIds }),
});
if (!linkRes.ok) {
const linkData = await linkRes.json().catch(() => null);
throw new Error((linkData as Record<string, string> | null)?.['hydra:description'] ?? 'Erreur lors de l\'association des compétences');
}
}
closeCreateModal();
await loadEvaluations();
} catch (e) {
@@ -486,6 +522,9 @@
<a class="btn-primary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/grades">
Saisir les notes
</a>
<a class="btn-secondary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/competencies">
Compétences
</a>
<button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}>
Modifier
</button>
@@ -587,35 +626,73 @@
/>
</div>
<div class="form-row">
<div class="form-group form-group-half">
<label for="ev-scale">Barème *</label>
<input
type="number"
id="ev-scale"
bind:value={newGradeScale}
min="1"
max="100"
step="1"
required
/>
{#if gradeScalePreview}
<small class="form-hint">{gradeScalePreview}</small>
{#if availableCompetencies.length > 0}
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" bind:checked={newIsCompetency} />
Évaluation par compétences
</label>
</div>
{/if}
{#if newIsCompetency}
<div class="form-group">
<label>Compétences évaluées *</label>
<div class="competency-checklist">
{#each availableCompetencies as comp (comp.id)}
<label class="checkbox-label">
<input
type="checkbox"
value={comp.id}
checked={newSelectedCompetencyIds.includes(comp.id)}
onchange={(e) => {
const target = e.currentTarget;
if (target.checked) {
newSelectedCompetencyIds = [...newSelectedCompetencyIds, comp.id];
} else {
newSelectedCompetencyIds = newSelectedCompetencyIds.filter((id) => id !== comp.id);
}
}}
/>
<span class="comp-code">{comp.code}</span> {comp.name}
</label>
{/each}
</div>
{#if newIsCompetency && newSelectedCompetencyIds.length === 0}
<small class="form-hint form-hint-warning">Sélectionnez au moins une compétence</small>
{/if}
</div>
{:else}
<div class="form-row">
<div class="form-group form-group-half">
<label for="ev-scale">Barème *</label>
<input
type="number"
id="ev-scale"
bind:value={newGradeScale}
min="1"
max="100"
step="1"
required
/>
{#if gradeScalePreview}
<small class="form-hint">{gradeScalePreview}</small>
{/if}
</div>
<div class="form-group form-group-half">
<label for="ev-coeff">Coefficient</label>
<input
type="number"
id="ev-coeff"
bind:value={newCoefficient}
min="0.1"
max="10"
step="0.1"
/>
<div class="form-group form-group-half">
<label for="ev-coeff">Coefficient</label>
<input
type="number"
id="ev-coeff"
bind:value={newCoefficient}
min="0.1"
max="10"
step="0.1"
/>
</div>
</div>
</div>
{/if}
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick={closeCreateModal}>Annuler</button>
@@ -1176,4 +1253,28 @@
gap: 0.5rem;
}
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
}
.competency-checklist {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.375rem;
}
.competency-checklist .comp-code {
font-weight: 600;
color: var(--color-primary, #3b82f6);
}
</style>

View File

@@ -0,0 +1,708 @@
<script lang="ts">
import { page } from '$app/stores';
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
interface Evaluation {
id: string;
title: string;
classId: string;
className: string | null;
subjectName: string | null;
evaluationDate: string;
gradesPublishedAt: string | null;
}
interface CompetencyLevel {
code: string;
name: string;
color: string | null;
sortOrder: number;
}
interface CompetencyEvaluation {
id: string;
competencyId: string;
competencyCode: string;
competencyName: string;
}
interface ResultCell {
studentId: string;
studentName: string;
competencyEvaluationId: string;
competencyCode: string;
competencyName: string;
levelCode: string | null;
dirty: boolean;
}
let evaluationId: string = $derived($page.params.id ?? '');
let evaluation: Evaluation | null = $state(null);
let levels: CompetencyLevel[] = $state([]);
let competencyEvaluations: CompetencyEvaluation[] = $state([]);
let results: ResultCell[] = $state([]);
let isLoading = $state(true);
let error: string | null = $state(null);
let saveStatus: string | null = $state(null);
let saveTimer: number | null = $state(null);
// Derived: unique students
let students = $derived.by(() => {
const seen = new Map<string, string>();
for (const r of results) {
if (!seen.has(r.studentId)) {
seen.set(r.studentId, r.studentName);
}
}
return [...seen.entries()].map(([id, name]) => ({ id, name }));
});
// Derived: unique competencies (in order)
let competencies = $derived.by(() => {
const seen = new Map<string, { code: string; name: string; ceId: string }>();
for (const ce of competencyEvaluations) {
if (!seen.has(ce.competencyId)) {
seen.set(ce.competencyId, {
code: ce.competencyCode,
name: ce.competencyName,
ceId: ce.id,
});
}
}
return [...seen.entries()].map(([id, v]) => ({ id, ...v }));
});
// Derived: result lookup by studentId-ceId for reactive template bindings
let resultMap = $derived.by(() => {
const map = new Map<string, ResultCell>();
for (const r of results) {
map.set(`${r.studentId}-${r.competencyEvaluationId}`, r);
}
return map;
});
// Keyboard shortcut map: 1=first level, 2=second, etc. (based on array index)
let levelByShortcut = $derived.by(() => {
const map = new Map<string, string>();
for (let i = 0; i < levels.length; i++) {
map.set(String(i + 1), levels[i]!.code);
}
return map;
});
$effect(() => {
if (evaluationId) {
loadAll();
}
});
$effect(() => {
return () => {
if (saveTimer !== null) {
window.clearTimeout(saveTimer);
}
};
});
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
await getAuthenticatedUserId();
// Load evaluation, levels, competency evaluations, and results in parallel
const [evalRes, levelsRes, ceRes, resultsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}`),
authenticatedFetch(`${apiUrl}/competency-levels`),
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}/competencies`),
authenticatedFetch(`${apiUrl}/evaluations/${evaluationId}/competency-results`),
]);
if (!evalRes.ok) throw new Error('Évaluation non trouvée');
if (!levelsRes.ok) throw new Error('Erreur lors du chargement des niveaux');
if (!ceRes.ok) throw new Error('Erreur lors du chargement des compétences');
if (!resultsRes.ok) throw new Error('Erreur lors du chargement des résultats');
evaluation = await evalRes.json();
const levelsData = await levelsRes.json();
levels = (levelsData['hydra:member'] ?? levelsData['member'] ?? (Array.isArray(levelsData) ? levelsData : [])) as CompetencyLevel[];
const ceData = await ceRes.json();
competencyEvaluations = (ceData['hydra:member'] ?? ceData['member'] ?? (Array.isArray(ceData) ? ceData : [])) as CompetencyEvaluation[];
const resultsData = await resultsRes.json();
const rawResults: Array<{
id: string | null;
competencyEvaluationId: string;
competencyId: string;
competencyCode: string;
competencyName: string;
studentId: string;
studentName: string | null;
levelCode: string | null;
}> = resultsData['hydra:member'] ?? resultsData['member'] ?? (Array.isArray(resultsData) ? resultsData : []);
results = rawResults.map((r) => ({
studentId: r.studentId,
studentName: r.studentName ?? '',
competencyEvaluationId: r.competencyEvaluationId,
competencyCode: r.competencyCode ?? '',
competencyName: r.competencyName ?? '',
levelCode: r.levelCode,
dirty: false,
}));
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function setLevel(studentId: string, ceId: string, levelCode: string) {
const idx = results.findIndex(
(r) => r.studentId === studentId && r.competencyEvaluationId === ceId,
);
if (idx === -1) return;
const current = results[idx]!;
// Toggle: if already same level, clear it
const newLevel = current.levelCode === levelCode ? null : levelCode;
results[idx] = { ...current, levelCode: newLevel, dirty: true };
scheduleAutoSave();
}
function handleCellKeydown(event: KeyboardEvent, studentId: string, ceId: string) {
const levelCode = levelByShortcut.get(event.key);
if (levelCode) {
event.preventDefault();
setLevel(studentId, ceId, levelCode);
return;
}
const cellIndex = getCellIndex(studentId, ceId);
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault();
focusNextCell(cellIndex);
} else if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
focusPrevCell(cellIndex);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
focusNextCell(cellIndex);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusPrevCell(cellIndex);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
focusCellBelow(studentId, ceId);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
focusCellAbove(studentId, ceId);
}
}
function getCellIndex(studentId: string, ceId: string): number {
const studentIdx = students.findIndex((s) => s.id === studentId);
const compIdx = competencies.findIndex((c) => c.ceId === ceId);
return studentIdx * competencies.length + compIdx;
}
function focusNextCell(currentIndex: number) {
const nextIndex = currentIndex + 1;
const totalCells = students.length * competencies.length;
if (nextIndex < totalCells) {
focusCellByIndex(nextIndex);
}
}
function focusPrevCell(currentIndex: number) {
const prevIndex = currentIndex - 1;
if (prevIndex >= 0) {
focusCellByIndex(prevIndex);
}
}
function focusCellBelow(studentId: string, ceId: string) {
const studentIdx = students.findIndex((s) => s.id === studentId);
if (studentIdx < students.length - 1) {
const nextStudent = students[studentIdx + 1]!;
const el = document.getElementById(`cell-${nextStudent.id}-${ceId}`);
el?.focus();
}
}
function focusCellAbove(studentId: string, ceId: string) {
const studentIdx = students.findIndex((s) => s.id === studentId);
if (studentIdx > 0) {
const prevStudent = students[studentIdx - 1]!;
const el = document.getElementById(`cell-${prevStudent.id}-${ceId}`);
el?.focus();
}
}
function focusCellByIndex(index: number) {
const studentIdx = Math.floor(index / competencies.length);
const compIdx = index % competencies.length;
const student = students[studentIdx];
const comp = competencies[compIdx];
if (student && comp) {
const el = document.getElementById(`cell-${student.id}-${comp.ceId}`);
el?.focus();
}
}
function scheduleAutoSave() {
if (saveTimer !== null) {
window.clearTimeout(saveTimer);
}
saveTimer = window.setTimeout(() => {
saveResults();
}, 500) as unknown as number;
}
async function saveResults() {
const dirtyResults = results.filter((r) => r.dirty);
if (dirtyResults.length === 0) return;
// Snapshot dirty IDs before async operation to avoid race condition
const dirtyKeys = new Set(dirtyResults.map((r) => `${r.studentId}-${r.competencyEvaluationId}`));
try {
saveStatus = 'Sauvegarde...';
const apiUrl = getApiBaseUrl();
const payload = dirtyResults.map((r) => ({
studentId: r.studentId,
competencyEvaluationId: r.competencyEvaluationId,
levelCode: r.levelCode,
}));
const response = await authenticatedFetch(
`${apiUrl}/evaluations/${evaluationId}/competency-results`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ results: payload }),
},
);
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(
(data as Record<string, string> | null)?.['hydra:description'] ??
(data as Record<string, string> | null)?.['message'] ??
'Erreur lors de la sauvegarde',
);
}
// Only mark saved the results that were actually sent
results = results.map((r) => {
const key = `${r.studentId}-${r.competencyEvaluationId}`;
return r.dirty && dirtyKeys.has(key) ? { ...r, dirty: false } : r;
});
saveStatus = 'Sauvegardé';
window.setTimeout(() => {
if (saveStatus === 'Sauvegardé') saveStatus = null;
}, 2000);
} catch (e) {
saveStatus = null;
error = e instanceof Error ? e.message : 'Erreur inconnue';
}
}
function getLevelName(levelCode: string | null): string {
if (!levelCode) return '';
const level = levels.find((l) => l.code === levelCode);
return level?.name ?? levelCode;
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
try {
return new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(dateStr));
} catch {
return dateStr;
}
}
</script>
<svelte:head>
<title>{evaluation?.title ?? 'Compétences'} - Évaluation par compétences</title>
</svelte:head>
<div class="competency-page">
<header class="page-header">
<div class="header-left">
<a href="/dashboard/teacher/evaluations" class="back-link">&larr; Retour aux évaluations</a>
{#if evaluation}
<h1>{evaluation.title}</h1>
<div class="header-meta">
{#if evaluation.className}
<span class="meta-tag">{evaluation.className}</span>
{/if}
{#if evaluation.subjectName}
<span class="meta-tag">{evaluation.subjectName}</span>
{/if}
<span class="meta-tag">{formatDate(evaluation.evaluationDate)}</span>
</div>
{/if}
</div>
{#if saveStatus}
<div class="save-indicator" class:saving={saveStatus === 'Sauvegarde...'}>
{saveStatus}
</div>
{/if}
</header>
{#if error}
<div class="error-banner" role="alert">
<p>{error}</p>
<button onclick={() => (error = null)}>Fermer</button>
</div>
{/if}
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement de la grille de compétences...</p>
</div>
{:else if competencies.length === 0}
<div class="empty-state">
<h2>Aucune compétence associée</h2>
<p>Cette évaluation n'a pas encore de compétences associées.</p>
</div>
{:else}
<!-- Level legend -->
<div class="level-legend">
<span class="legend-label">Niveaux :</span>
{#each levels as level, i (level.code)}
<span class="legend-item" style="--level-color: {level.color}">
<span class="legend-key">{i + 1}</span>
<span class="legend-dot" style="background-color: {level.color}"></span>
{level.name}
</span>
{/each}
</div>
<!-- Competency grid -->
<div class="grid-container" role="grid" aria-label="Grille de compétences">
<table class="competency-grid">
<thead>
<tr>
<th class="student-col" scope="col">Élève</th>
{#each competencies as comp (comp.ceId)}
<th class="competency-col" scope="col" title={comp.name}>
<span class="comp-code">{comp.code}</span>
<span class="comp-name">{comp.name}</span>
</th>
{/each}
</tr>
</thead>
<tbody>
{#each students as student (student.id)}
<tr>
<td class="student-name">{student.name}</td>
{#each competencies as comp (comp.ceId)}
{@const cellKey = `${student.id}-${comp.ceId}`}
<td
class="level-cell"
class:dirty={resultMap.get(cellKey)?.dirty}
id="cell-{student.id}-{comp.ceId}"
tabindex="0"
role="gridcell"
aria-label="{student.name} - {comp.name}: {getLevelName(resultMap.get(cellKey)?.levelCode ?? null)}"
onkeydown={(e) => handleCellKeydown(e, student.id, comp.ceId)}
>
<div class="level-buttons">
{#each levels as level, levelIdx (level.code)}
<button
class="level-btn"
class:active={resultMap.get(cellKey)?.levelCode === level.code}
style="--btn-color: {level.color}"
title="{level.name} (Raccourci: {levelIdx + 1})"
onclick={() => setLevel(student.id, comp.ceId, level.code)}
>
{levelIdx + 1}
</button>
{/each}
</div>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.competency-page {
padding: 1.5rem;
max-width: 100%;
overflow-x: auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
gap: 1rem;
}
.back-link {
color: var(--color-primary, #3b82f6);
text-decoration: none;
font-size: 0.875rem;
margin-bottom: 0.5rem;
display: inline-block;
}
.back-link:hover {
text-decoration: underline;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.header-meta {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.meta-tag {
background: var(--color-surface-alt, #f1f5f9);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
color: var(--color-text-secondary, #64748b);
}
.save-indicator {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
font-size: 0.875rem;
font-weight: 500;
white-space: nowrap;
}
.save-indicator.saving {
background: var(--color-warning-bg, #fef9c3);
color: var(--color-warning, #ca8a04);
}
.error-banner {
background: var(--color-error-bg, #fee2e2);
color: var(--color-error, #dc2626);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.error-banner p {
margin: 0;
}
.error-banner button {
background: none;
border: none;
color: var(--color-error, #dc2626);
cursor: pointer;
font-weight: 600;
}
.loading-state,
.empty-state {
text-align: center;
padding: 3rem;
color: var(--color-text-secondary, #64748b);
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e2e8f0);
border-top-color: var(--color-primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.level-legend {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-surface-alt, #f8fafc);
border-radius: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
font-size: 0.875rem;
}
.legend-label {
font-weight: 600;
color: var(--color-text, #1e293b);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-secondary, #64748b);
}
.legend-key {
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.25rem;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.legend-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
}
.grid-container {
overflow-x: auto;
border-radius: 0.5rem;
border: 1px solid var(--color-border, #e2e8f0);
}
.competency-grid {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.competency-grid th {
background: var(--color-surface-alt, #f8fafc);
padding: 0.75rem 0.5rem;
font-weight: 600;
text-align: center;
border-bottom: 2px solid var(--color-border, #e2e8f0);
white-space: nowrap;
}
.student-col {
text-align: left !important;
min-width: 180px;
position: sticky;
left: 0;
z-index: 1;
}
.competency-col {
min-width: 120px;
}
.comp-code {
display: block;
font-weight: 700;
font-size: 0.8rem;
}
.comp-name {
display: block;
font-weight: 400;
font-size: 0.7rem;
color: var(--color-text-secondary, #64748b);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.competency-grid td {
padding: 0.375rem;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.student-name {
font-weight: 500;
padding-left: 0.75rem !important;
background: var(--color-surface, #fff);
position: sticky;
left: 0;
z-index: 1;
}
.level-cell {
text-align: center;
outline: none;
}
.level-cell:focus-within {
box-shadow: inset 0 0 0 2px var(--color-primary, #3b82f6);
border-radius: 0.25rem;
}
.level-cell.dirty {
background: var(--color-warning-bg, #fefce8);
}
.level-buttons {
display: flex;
gap: 0.25rem;
justify-content: center;
}
.level-btn {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
border: 2px solid var(--color-border, #e2e8f0);
background: var(--color-surface, #fff);
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #94a3b8);
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
}
.level-btn:hover {
border-color: var(--btn-color, #3b82f6);
color: var(--btn-color, #3b82f6);
}
.level-btn.active {
background: var(--btn-color, #3b82f6);
border-color: var(--btn-color, #3b82f6);
color: #fff;
}
</style>