feat(class): landing class page
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user