feat: Calculer automatiquement les moyennes après chaque saisie de notes
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:
@@ -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>
|
||||
64
frontend/src/lib/stores/appreciationOfflineStore.ts
Normal file
64
frontend/src/lib/stores/appreciationOfflineStore.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user