feat: Permettre à l'enseignant de saisir les notes dans une grille inline
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'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
2026-03-29 09:55:45 +02:00
parent 98be1951bf
commit b70d5ec2ad
45 changed files with 3902 additions and 11 deletions

View File

@@ -0,0 +1,83 @@
const DB_NAME = 'classeo-grades';
const DB_VERSION = 1;
const STORE_NAME = 'pending-grades';
interface PendingGrade {
evaluationId: string;
studentId: string;
value: number | null;
status: string;
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 savePendingGrade(grade: PendingGrade): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(grade);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function savePendingGrades(grades: PendingGrade[]): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const grade of grades) {
store.put(grade);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getPendingGrades(evaluationId: string): Promise<PendingGrade[]> {
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 PendingGrade[]);
request.onerror = () => reject(request.error);
});
}
export async function clearPendingGrades(evaluationId: string): Promise<void> {
const db = await openDb();
const pending = await getPendingGrades(evaluationId);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const grade of pending) {
store.delete([grade.evaluationId, grade.studentId]);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function hasPendingGrades(evaluationId: string): Promise<boolean> {
const pending = await getPendingGrades(evaluationId);
return pending.length > 0;
}

View File

@@ -483,6 +483,9 @@
{#if ev.status === 'published'}
<div class="evaluation-actions">
<a class="btn-primary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/grades">
Saisir les notes
</a>
<button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}>
Modifier
</button>

File diff suppressed because it is too large Load Diff