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>
|
||||
Reference in New Issue
Block a user