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.
188 lines
3.9 KiB
Svelte
188 lines
3.9 KiB
Svelte
<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>
|