Files
notytex/frontend/src/views/ClassDashboardView.vue

310 lines
13 KiB
Vue

<template>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<LoadingSpinner v-if="loading" text="Chargement..." fullPage />
<template v-else-if="classData">
<!-- Hero amélioré -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-100 rounded-xl p-6 md:p-8 mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">{{ classData.name }}</h1>
<p class="text-gray-600">{{ classData.year }} - {{ classData.students_count }} élèves</p>
</div>
<div class="flex gap-2">
<router-link :to="`/classes/${classData.id}/students`" class="btn btn-secondary">
Élèves
</router-link>
<router-link :to="`/classes/${classData.id}/council`" class="btn btn-secondary">
Conseil
</router-link>
</div>
</div>
</div>
<!-- Trimester selector -->
<div class="flex gap-2 mb-6">
<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>
<!-- Stats principales - Grid 4 colonnes -->
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Moyenne classe -->
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
<p class="text-sm text-gray-500 mb-1">Moyenne classe</p>
<p class="text-3xl font-bold text-orange-600">{{ stats.mean?.toFixed(2) || '-' }}</p>
<div class="mt-2 flex justify-between text-xs text-gray-400">
<span>Min: {{ stats.min_score?.toFixed(1) || '-' }}</span>
<span>Max: {{ stats.max_score?.toFixed(1) || '-' }}</span>
</div>
</div>
<!-- Médiane -->
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
<p class="text-sm text-gray-500 mb-1">Médiane</p>
<p class="text-3xl font-bold text-blue-600">{{ stats.median?.toFixed(2) || '-' }}</p>
<div class="mt-2 text-xs text-gray-400">
Écart-type: {{ stats.std_dev?.toFixed(2) || '-' }}
</div>
</div>
<!-- Évaluations -->
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
<p class="text-sm text-gray-500 mb-1">Évaluations</p>
<p class="text-3xl font-bold text-gray-900">{{ stats.assessments_total || 0 }}</p>
<div class="mt-2 flex space-x-2 text-xs">
<span class="text-green-600">{{ stats.assessments_completed || 0 }} terminées</span>
<span class="text-orange-600">{{ stats.assessments_in_progress || 0 }} en cours</span>
</div>
</div>
<!-- Élèves -->
<div class="bg-white rounded-xl shadow-md p-6 hover:shadow-lg transition-all duration-300">
<p class="text-sm text-gray-500 mb-1">Élèves</p>
<p class="text-3xl font-bold text-gray-900">{{ stats.students_count || 0 }}</p>
<div class="mt-2 text-xs text-gray-400">
{{ stats.student_averages?.length || 0 }} avec moyenne
</div>
</div>
</div>
<!-- Domaines et Compétences en 2 colonnes -->
<div v-if="stats" class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- Domaines -->
<div v-if="stats.domains_stats?.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>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4">
<div v-for="domain in stats.domains_stats" :key="domain.id" class="space-y-1">
<div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="domain.name">{{ domain.name }}</span>
<span class="font-bold" :style="{ color: domain.color || '#6B7280' }">
{{ domain.total_points_possible?.toFixed(1) || '0' }} points
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{
width: `${getRelativeWidth(domain, stats.domains_stats)}%`,
backgroundColor: domain.color || '#6B7280'
}"
></div>
</div>
<p class="text-xs text-gray-400">
{{ domain.evaluation_count }} élément(s) de notation
</p>
</div>
</div>
</div>
<div v-else class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Évaluations par domaine</h2>
<p class="text-sm text-gray-500 italic">Aucune donnée de domaine disponible</p>
</div>
<!-- Compétences -->
<div v-if="stats.competences_stats?.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>
<p class="text-xs text-gray-500 mb-4">Perspective enseignant : ce qui a été évalué</p>
<div class="space-y-4">
<div v-for="competence in stats.competences_stats" :key="competence.id" class="space-y-1">
<div class="flex justify-between text-sm">
<span class="font-medium text-gray-700 truncate" :title="competence.name">{{ competence.name }}</span>
<span class="font-bold" :style="{ color: competence.color || '#6B7280' }">
{{ competence.total_points_possible?.toFixed(1) || '0' }} points
</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div
class="h-2 rounded-full transition-all duration-1000 ease-out"
:style="{
width: `${getRelativeWidth(competence, stats.competences_stats)}%`,
backgroundColor: competence.color || '#6B7280'
}"
></div>
</div>
<p class="text-xs text-gray-400">
{{ competence.evaluation_count }} élément(s) de notation
</p>
</div>
</div>
</div>
<div v-else 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>
<p class="text-sm text-gray-500 italic">Aucune donnée de compétence disponible</p>
</div>
</div>
<!-- Students averages -->
<div v-if="stats?.student_averages?.length" class="bg-white rounded-xl shadow-md overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">Notes par élève</h2>
<p class="text-xs text-gray-500 mt-1">Cliquez sur les en-têtes pour trier</p>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th
@click="sortBy('name')"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
Élève {{ getSortIcon('name') }}
</th>
<th
@click="sortBy('average')"
class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
>
Moyenne {{ getSortIcon('average') }}
</th>
<th
v-for="assessment in assessments"
:key="assessment.assessment_id"
@click="sortBy(`assessment_${assessment.assessment_id}`)"
class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
:title="assessment.assessment_title"
>
<div class="flex flex-col items-center">
<span class="truncate max-w-[120px]">{{ assessment.assessment_title }}</span>
<span class="text-[10px] font-normal">{{ getSortIcon(`assessment_${assessment.assessment_id}`) }}</span>
</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="student in sortedStudents" :key="student.student_id" class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ student.last_name }} {{ student.first_name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-right font-bold text-blue-600">
{{ student.average?.toFixed(2) || '-' }}
</td>
<td
v-for="assessment in assessments"
:key="assessment.assessment_id"
class="px-4 py-4 whitespace-nowrap text-sm text-center text-gray-700"
>
{{ getAssessmentScore(student, assessment.assessment_id) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else-if="stats" class="bg-white rounded-xl shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Notes par élève</h2>
<p class="text-sm text-gray-500 italic text-center py-4">Aucune note disponible pour ce trimestre</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useClassesStore } from '@/stores/classes'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
const route = useRoute()
const classesStore = useClassesStore()
const loading = ref(true)
const classData = ref(null)
const stats = ref(null)
const trimester = ref(1)
const sortColumn = ref('name')
const sortDirection = ref('asc')
async function fetchData() {
loading.value = true
try {
const id = route.params.id
classData.value = await classesStore.fetchClass(id)
stats.value = await classesStore.fetchClassStats(id, trimester.value)
} finally {
loading.value = false
}
}
async function selectTrimester(t) {
trimester.value = t
stats.value = await classesStore.fetchClassStats(route.params.id, t)
}
// Récupérer la liste des évaluations triée par date
const assessments = computed(() => {
if (!stats.value?.student_averages?.length) return []
// Extraire les évaluations depuis le premier élève
const firstStudent = stats.value.student_averages[0]
if (!firstStudent?.assessment_scores) return []
return Object.values(firstStudent.assessment_scores).sort((a, b) => a.assessment_id - b.assessment_id)
})
// Fonction de tri des élèves
const sortedStudents = computed(() => {
if (!stats.value?.student_averages) return []
const students = [...stats.value.student_averages]
students.sort((a, b) => {
let valA, valB
if (sortColumn.value === 'name') {
valA = `${a.last_name} ${a.first_name}`.toLowerCase()
valB = `${b.last_name} ${b.first_name}`.toLowerCase()
} else if (sortColumn.value === 'average') {
valA = a.average ?? -1
valB = b.average ?? -1
} else if (sortColumn.value.startsWith('assessment_')) {
const assessmentId = parseInt(sortColumn.value.split('_')[1])
valA = a.assessment_scores?.[assessmentId]?.score ?? -1
valB = b.assessment_scores?.[assessmentId]?.score ?? -1
} else {
return 0
}
const comparison = valA > valB ? 1 : valA < valB ? -1 : 0
return sortDirection.value === 'asc' ? comparison : -comparison
})
return students
})
function sortBy(column) {
if (sortColumn.value === column) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortColumn.value = column
sortDirection.value = 'asc'
}
}
function getAssessmentScore(student, assessmentId) {
const score = student.assessment_scores?.[assessmentId]
if (!score || score.score === null) return '-'
return `${score.score.toFixed(1)}/${score.max_points.toFixed(0)}`
}
function getSortIcon(column) {
if (sortColumn.value !== column) return ''
return sortDirection.value === 'asc' ? '▲' : '▼'
}
function getRelativeWidth(item, allItems) {
const maxPoints = Math.max(...allItems.map(d => d.total_points_possible || 0))
if (maxPoints === 0) return 0
return ((item.total_points_possible || 0) / maxPoints) * 100
}
onMounted(fetchData)
</script>