feat(class): landing class page
All checks were successful
Build and Publish Docker Images / Build Backend Image (push) Successful in 2m52s
Build and Publish Docker Images / Build Frontend Image (push) Successful in 3m7s
Build and Publish Docker Images / Build Summary (push) Successful in 3s

This commit is contained in:
2025-12-09 16:02:47 +01:00
parent f76b033d55
commit ba25dd19db
3 changed files with 70 additions and 26 deletions

View File

@@ -225,17 +225,21 @@ async def get_class_students(
@router.get("/{class_id}/stats", response_model=ClassDashboardStats) @router.get("/{class_id}/stats", response_model=ClassDashboardStats)
async def get_class_stats( async def get_class_stats(
class_id: int, class_id: int,
trimester: int,
session: AsyncSessionDep, session: AsyncSessionDep,
trimester: Optional[int] = Query(None, description="Trimestre (1, 2, 3) ou None pour vision annuelle"),
): ):
""" """
Récupère les statistiques complètes d'une classe pour un trimestre. Récupère les statistiques complètes d'une classe pour un trimestre ou toute l'année.
Inclut: Inclut:
- Moyennes par élève avec détail par évaluation - Moyennes par élève avec détail par évaluation
- Statistiques globales (moyenne, médiane, écart-type) - Statistiques globales (moyenne, médiane, écart-type)
- Histogramme des moyennes - Histogramme des moyennes
- Analyse par domaines et compétences (nombre d'évaluations + points) - Analyse par domaines et compétences (nombre d'évaluations + points)
Args:
class_id: ID de la classe
trimester: Trimestre spécifique (1, 2, 3) ou None pour vision annuelle (toutes évaluations)
""" """
# Vérifier que la classe existe # Vérifier que la classe existe
class_query = select(ClassGroup).where(ClassGroup.id == class_id) class_query = select(ClassGroup).where(ClassGroup.id == class_id)
@@ -258,18 +262,20 @@ async def get_class_stats(
students_result = await session.execute(students_query) students_result = await session.execute(students_query)
students = students_result.scalars().all() students = students_result.scalars().all()
# Récupérer les évaluations du trimestre avec leurs relations # Récupérer les évaluations (trimestre spécifique ou toutes)
assessments_query = ( assessments_query = (
select(Assessment) select(Assessment)
.options( .options(
selectinload(Assessment.exercises).selectinload(Exercise.grading_elements) selectinload(Assessment.exercises).selectinload(Exercise.grading_elements)
) )
.where( .where(Assessment.class_group_id == class_id)
Assessment.class_group_id == class_id,
Assessment.trimester == trimester
)
.order_by(Assessment.date)
) )
# Filtrer par trimestre seulement si spécifié
if trimester is not None:
assessments_query = assessments_query.where(Assessment.trimester == trimester)
assessments_query = assessments_query.order_by(Assessment.date)
assessments_result = await session.execute(assessments_query) assessments_result = await session.execute(assessments_query)
assessments = assessments_result.scalars().all() assessments = assessments_result.scalars().all()

View File

@@ -186,7 +186,7 @@ class ClassDashboardStats(BaseSchema):
class_id: int class_id: int
class_name: str class_name: str
trimester: int trimester: Optional[int] = None # None = vision annuelle
students_count: int students_count: int
# Statistiques globales # Statistiques globales

View File

@@ -22,16 +22,38 @@
</div> </div>
<!-- Trimester selector --> <!-- Trimester selector -->
<div class="flex gap-2 mb-6"> <div class="mb-6">
<button <div class="flex flex-wrap gap-2 items-center">
v-for="t in [1, 2, 3]" <!-- Vision annuelle -->
:key="t" <button
@click="selectTrimester(t)" @click="selectTrimester(null)"
class="btn" class="btn"
:class="trimester === t ? 'btn-primary' : 'btn-secondary'" :class="trimester === null ? 'btn-primary' : 'btn-secondary'"
> >
Trimestre {{ t }} 📊 Vision annuelle
</button> </button>
<!-- Séparateur visuel -->
<div class="border-l border-gray-300 h-8 mx-1"></div>
<!-- Trimestres individuels -->
<button
v-for="t in [1, 2, 3]"
:key="t"
@click="selectTrimester(t)"
class="btn"
:class="trimester === t ? 'btn-primary' : 'btn-secondary'"
>
Trimestre {{ t }}
</button>
</div>
<!-- Indicateur de période affichée -->
<div class="mt-3 text-center">
<p class="text-sm font-medium text-gray-600">
{{ trimester === null ? '📊 Toutes les évaluations de l\'année' : `📅 Évaluations du trimestre ${trimester}` }}
</p>
</div>
</div> </div>
<!-- Stats principales - Grid 4 colonnes --> <!-- Stats principales - Grid 4 colonnes -->
@@ -78,11 +100,11 @@
<!-- Domaines et Compétences en 2 colonnes --> <!-- Domaines et Compétences en 2 colonnes -->
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8"> <div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Domaines --> <!-- Domaines -->
<div v-if="stats.domains_stats?.length" class="bg-white rounded-xl shadow-md p-6"> <div v-if="sortedDomainsStats.length" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p> <p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1"> <div v-for="domain in sortedDomainsStats" :key="domain.id" class="space-y-1">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span> <span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }"> <span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
@@ -93,7 +115,7 @@
<div <div
class="h-2 rounded-full transition-all duration-1000 ease-out" class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{ :style="{
width: `${getRelativeWidth(domain, stats.domains_stats)}%`, width: `${getRelativeWidth(domain, sortedDomainsStats)}%`,
backgroundColor: domain.color || '#6B7280' backgroundColor: domain.color || '#6B7280'
}" }"
></div> ></div>
@@ -110,11 +132,11 @@
</div> </div>
<!-- Compétences --> <!-- Compétences -->
<div v-if="stats.competences_stats?.length" class="bg-white rounded-xl shadow-md p-6"> <div v-if="sortedCompetencesStats.length" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2> <h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par compétence</h2>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p> <p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1"> <div v-for="competence in sortedCompetencesStats" :key="competence.id" class="space-y-1">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span> <span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }"> <span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
@@ -125,7 +147,7 @@
<div <div
class="h-2 rounded-full transition-all duration-1000 ease-out" class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{ :style="{
width: `${getRelativeWidth(competence, stats.competences_stats)}%`, width: `${getRelativeWidth(competence, sortedCompetencesStats)}%`,
backgroundColor: competence.color || '#6B7280' backgroundColor: competence.color || '#6B7280'
}" }"
></div> ></div>
@@ -218,7 +240,7 @@ const classesStore = useClassesStore()
const loading = ref(true) const loading = ref(true)
const classData = ref(null) const classData = ref(null)
const stats = ref(null) const stats = ref(null)
const trimester = ref(1) const trimester = ref(null) // null = vision annuelle par défaut
const sortColumn = ref('name') const sortColumn = ref('name')
const sortDirection = ref('asc') const sortDirection = ref('asc')
@@ -305,5 +327,21 @@ function getRelativeWidth(item, allItems) {
return ((item.total_points_possible || 0) / maxPoints) * 100 return ((item.total_points_possible || 0) / maxPoints) * 100
} }
// Tri des domaines et compétences
const sortedDomainsStats = computed(() => {
if (!stats.value?.domains_stats) return []
// Trier par nom alphabétiquement
return [...stats.value.domains_stats].sort((a, b) =>
a.name.localeCompare(b.name, 'fr', { sensitivity: 'base' })
)
})
const sortedCompetencesStats = computed(() => {
if (!stats.value?.competences_stats) return []
// Les compétences sont déjà triées par order_index côté backend
// On retourne une copie pour être cohérent
return [...stats.value.competences_stats]
})
onMounted(fetchData) onMounted(fetchData)
</script> </script>